Repository navigation
Description
Summary
The background service (t3 serve, runtime 0.0.42) exhausts the V8 heap and
aborts after roughly a day to two days of continuous uptime, under sustained
agent-session load. It has happened twice in four days on the same host, with an
identical stack. Restart=always recovers it in ~25 seconds, but the restart
SIGKILLs everything in the cgroup, including in-flight provider sessions.
Occurrences
crash 1 2026-09-18 05:48:00Z uptime 48.0h cgroup memory peak 18.4 G
crash 2 2026-09-19 11:35:36Z uptime 29.8h cgroup memory peak 15.4 G
Both SIGABRT, both V8 FatalProcessOutOfMemory on the main thread:
node!v8::internal::V8::FatalProcessOutOfMemory
node!v8::internal::Heap::FatalProcessOutOfMemory
node!v8::internal::Heap::CollectGarbage
node!v8::internal::HeapAllocator::AllocateRawWithLightRetrySlowPath
No --max-old-space-size and no NODE_OPTIONS are set, so the server runs at
V8's internal default ceiling.
RSS growth on the current process, for shape: 230 MB after 90 minutes of
uptime. Small at start, fatal after a day-plus.
Workload
A multi-agent orchestration host: many long-lived provider sessions, frequent
thread/turn churn, and git operations against numerous worktrees. The second
window consumed 13h57m of CPU over 29.8h wall-clock, so the process is busy
rather than idle.
The projection database (state.sqlite) held ~16.7k orchestration events and
~430 live session bindings at the time of the second crash, if session or
projection state is a plausible retention site.
On the interval
48.0h then 29.8h. I am not claiming acceleration — two points cannot show a
trend, and the shorter window also carried heavier load, which fits the same
data. What the data does support is that it is reproducible under sustained use
and is not a one-off.
Impact
Recovery is automatic and fast, but the cgroup teardown is not graceful. The two
crashes killed, respectively:
crash 1: 2x node children, 1x git
crash 2: 1x claude (a provider session), 1x bash, 1x cat
A provider session killed mid-run leaves no completion record, so from the
orchestrator's side the work simply stops. A git process killed mid-write is a
worse case — a wedged index or stale lock in the affected worktree.
Environment
runtime 0.0.42 (SEA binary, ./t3 __service-launcher)
t3code-bin 0.0.38-1 (Arch)
OS Arch Linux, kernel 7.1.9, systemd 261
host 8-core / 16-thread, 29 GiB RAM, zram swap
service user unit, Restart=always, StartLimitBurst=5/300s
heap cap none set
What would help
Happy to gather more from this host if it is useful — I can run the server with
--max-old-space-size set and --heapsnapshot-near-heap-limit=1 to capture a
snapshot at the ceiling rather than losing the process, if that would be worth
more than the figures above. Say what would be most useful and I will collect it
on the next occurrence rather than guess.
Not asking for
No workaround needed on my side — automatic restart is holding. Filing because
a long-running service reaching V8's ceiling under normal use looks like
something you would want to know about, and no existing issue covers it.
Activity
juliusmarminge commentedon Sep 19, 2026
Triage
Confirmed as a real background-service V8 heap abort, not a one-off OOM from a single oversized payload and not a misconfigured unit. Reproduced twice on v0.0.42 (
719a76ca1d) with the same main-thread stack. Currentmain@dfbb11bddstill has the long-lived retention paths that fit this shape. I did not find a duplicate.Related, not the same ticket:
main(forkParkedstill inherits the caller span). It is a contributor, not this crash: idle growth alone does not reach a V8 ceiling in 30–48h.main, after 0.0.42) — bound provider event-log serialization.main, after 0.0.42) — drop oversized PR diff cache entries.t3code/codex-turn-mapping, not onmain).getThreadProjectionwith paging (open).provider_session_runtimerows when a thread is deleted (open).None of those is a multi-day busy-service OOM under hundreds of live sessions.
What the report shows
Two
SIGABRTs, bothv8::internal::V8::FatalProcessOutOfMemoryon the server thread duringHeap::CollectGarbage/AllocateRawWithLightRetrySlowPath:RSS 230 MB after 90 minutes, then fatal after a day-plus. That is retention, not a single huge allocation. The 15–18 GiB cgroup peaks include children (
claude,git, extranode). The abort itself is the server process V8 heap.Workload matches the expensive path: many long-lived provider sessions, frequent turn churn, lots of git/worktree work, ~16.7k orchestration events, ~430 live session bindings. 13h57m CPU / 29.8h wall on the second window.
The generated user unit still ships with no heap cap:
t3code/apps/server/src/cloud/bootService.ts
Lines 95 to 125 in dfbb11b
Environment=is onlyT3CODE_HOMEand the unit name. NoNODE_OPTIONS, no--max-old-space-size.KillMode=mixed+Restart=alwaysis why recovery is ~25s and why the cgroup teardown SIGKILLs in-flight provider/git children.OOMPolicy=continueonly covers kernel OOM of a child, not a V8 abort of the main process.What the process still retains
Several independent sites can grow for the life of
t3 serve. Together they fit “small at 90 minutes, dead after 1–2 days” on a busy orchestration host.1. Adapter session maps keep every completed turn.
Claude’s in-memory session is a process-lifetime
Map, and each finished turn is appended — not replaced:itemsis the turn’s tool/content payload. The array is only rewritten on rollback. Codex / Cursor / OpenCode / Grok / Antigravity keep the same kind ofMap<ThreadId, …SessionContext>.stopSessiondeletes the entry; if the session is still considered live, the history stays.2. The inactivity reaper will not touch a “busy” or background-live session.
t3code/apps/server/src/provider/Layers/ProviderSessionReaper.ts
Lines 42 to 95 in dfbb11b
Default idle window is 30 minutes. It skips
stopped, anything withactiveTurnId, and anything withbackgroundLiveness(working/monitoring— subagent fleets, workflow runs, Monitor loops). On this host that is the intended workload, so ~430 live bindings can be real in-process sessions, not leftover SQLite rows.ThreadBackgroundLivenessis in-memory and only clears ontask.completed(terminal status) orsession.exited. A monitor/subagent that never emits a terminal event keeps the session off-limits to the reaper.Open #8796 is the other half: deleted threads stop the session but leave the
provider_session_runtimerow. That inflates the binding count; it does not by itself keep adapter maps warm.3. Live event / worker queues are unbounded.
Every adapter publishes on
Queue.unbounded<ProviderRuntimeEvent>(). Ingestion, checkpoint, PR, and command reactors usemakeDrainableWorker(TxQueue.unbounded). A slow git/diff/ingestion worker holds every queued event, including diffs and tool payloads, until it drains. #12345 is the same class of bug and is not onmain.4. Immortal parent spans (#5410) are still on
main.forkParkedstill doesEffect.forkScoped(effect)with the caller’s span:t3code/apps/server/src/serverActivation.ts
Lines 12 to 26 in dfbb11b
Local file tracing is always on (
server.trace.ndjson, Info + timing). Under idle load this was 25–30 MB/h; under this host’s turn/git churn it will be worse. #9824 is the proposed detach. Not merged.5. Full thread projections are still unbounded (#11514).
orchestration.getThreadProjectionstill materializes every turn/payload/checkpoint for a long thread. A reconnect or snapshot refresh of many busy threads is a heap spike on top of the leak.16.7k orchestration events in SQLite is not large by itself. It only hurts if those rows (or their payloads) are decoded and held — replay, projection, live catch-up, or the completion-queue path above.
What this is not
FatalProcessOutOfMemoryon the server thread.--max-old-space-sizeonly delays the abort. The generated unit also has no place to put it without a systemd drop-in.Suggested direction
Do not start with a larger heap. Bound the process-lifetime sets:
turns[].main).provider.session.reaper.skipped-background-work/skipped-active-turnon this class of host — stuck liveness is how 430 “live” sessions stay in the Map.Graceful restart is a follow-up, not the fix.
KillMode=mixedwill still SIGKILL the cgroup if the main process aborts.What would help on the next occurrence
The offer of
--heapsnapshot-near-heap-limit=1is the right next artifact. Also useful, if cheap:process.memoryUsage()/v8.getHeapStatistics()on a 15–30 min timer (heapUsed vs external vs rss).provider_session_runtimebystatus,adapter.listSessions()vs that table, and reaper skip reasons from the server log.server.trace.ndjsonis rotating (traceMaxBytesis 10 MiB) or the file is huge.Raising the heap via a systemd drop-in is a valid delay, not a diagnosis:
Then
systemctl --user daemon-reload && systemctl --user restart <unit>. Snapshots land in the service cwd (%h). Redact before attaching.No workaround is required for the reporter —
Restart=alwaysis already holding. Filing this is correct; a long-runningt3 serveshould not hit the V8 ceiling under normal sustained use.OscarsOrbit commentedon Oct 15, 2026
Data from another long-running host, from T3's own trace spans and a source read. No heap contents are shared.
Runtime
0.0.46-nightly.20261008.2833(a6ec88f), SEA linux-x64, Node 26.8.2, V8 14.6.202.34, in a container. Our build carries a small local patch (MCP wiring and provider instructions only); it touches none of the files named below.NODE_OPTIONS=--max-old-space-size=12288 --heapsnapshot-near-heap-limit=1 --diagnostic-dir=<dir>. RSS was about 1.0 GiB shortly after.What we saw
server.heapSnapshotspan took 8.3 s and succeeded, writing a 267 MB file. The nextserver.eventLoop.stallspan, 23 s later, hadrssMb4337. RSS settled near 5.5 GiB (high-water mark about 5.9 GiB) and stayed there. Stalls of 3-5 s recurred from then on.Source note
v8.writeHeapSnapshotleaves V8'sHeapObjectsMapallocated and object-move tracking on for the life of the process.HeapProfiler::TakeSnapshotsetsis_tracking_object_moves_ = true(deps/v8/src/profiler/heap-profiler.cc:167at nodejs/node v26.8.2, f2f2c2f2). OnlyClearHeapObjectMap(:344-353) turns it off, and Node'swriteHeapSnapshotpath never calls it. Every later scavenge and compaction then reports each moved object to the profiler. So the SIGUSR2 handler (apps/server/src/observability/HeapSnapshot.ts:24,:46-49at 26fffe5) can permanently raise RSS and per-GC cost on a long-running server. This is from source; we have not measured how much of our rise is the map and how much is the allocator keeping memory.Suggestions
node:inspectorSession:HeapProfiler.takeHeapSnapshot, thenHeapProfiler.disable, which callsClearObjectIds(deps/v8/src/inspector/v8-heap-profiler-agent-impl.cc:333-341, same node commit). Otherwise, document that the residue lasts until restart.heapUsed,heapTotal,external,arrayBuffersand the heap limit on theserver.eventLoop.stallspan next torssMb(apps/server/src/observability/EventLoopMonitor.ts:146,:151at 26fffe5). That is the triage ask's data, taken at the moment that matters, and it separates GC-near-limit stalls from sync-I/O stalls without a snapshot.Disk
Disk, not heap. Our
statev2.sqliteis 10.24 GB;orchestration_eventsis 6.48 GB of it (63%) over about 1.70M rows, andorchestration_v2_eventsis empty. Since migration 055orchestration_eventsalso holds every v2 event, so we cannot yet say how much is v1 history.compactEventStore(apps/server/src/orchestration-v2/ProjectionMaintenance.ts:242at 5a4e998) has no caller outside tests, which matches #18559.Offer
We will take no further manual snapshot. If the near-limit snapshot fires, we will post a summary here: retainer classes and their sizes, never heap contents.
juliusmarminge commentedon Oct 22, 2026
Checked current main 89fe509232 on Linux x86_64 / Node 24.21.0 with the real web/server dev app and isolated copied state (3 stopped threads, 341 events). Browser-driven reads completed 200 shell/empty-thread requests and 200 populated-thread requests, all HTTP 200, without a V8 abort. This is a short read-load check, not a reproduction of 430 live sessions over 30–48 hours; it does not establish that the bug is fixed.
Runtime transcript, commands, results and limitations.
No matching duplicate or existing fix PR found. Main includes #19305, but its native SQLite retention fix does not establish a fix for this V8 heap abort.
Does this still occur on a current nightly? Please include the exact build, provider mix, approximate concurrent active sessions and turns/hour, and whether growth occurs while idle too. For a recurrence, a summary of heapUsed/heapTotal/external/arrayBuffers versus RSS and the largest retained object classes/retainer paths would distinguish the suspected causes. Please do not post raw heap snapshots; they can contain conversation content and secrets. The follow-up host report already notes snapshot-related overhead, so no additional manual snapshot is requested.
athammer commentedon Oct 27, 2026
Still happening on t3 v0.1.0: SEA build dated Oct 15, linux-x64, Node v26.8.2, bundling effect 4.0.4, headless user service. I haven't tried a current nightly yet. There have been five V8 heap aborts since Oct 24, and I have a reproduction for what looks like the main cause on this host: a request whose client disconnects early keeps its whole encoded JSON response in memory.
At every abort, the last full Mark-Compact freed almost nothing (for example
4091.1 -> 4088.0 MB) against the default 4,192 MB limit.Heap breakdown on the live server
I collected these through the inspector:
process.memoryUsage()and V8 heap space statistics every minute. No snapshot was taken.Almost all of the growth is in
large_object_space(7-13 MB/min) and ArrayBuffers (4-8 MB/min); old space stays flat at about 230 MB.The sampling heap profiler (128 KiB interval) attributes 802 of 909 MB (88%) of still-live sampled allocations over its first hour to the JSON encoder in
effect@4.0.4http-api/HttpApiBuilder.js,getResponseEncode, which callsJSON.stringify(e)and thentext(...). That is the allocation site, not a retainer path. I didn't take a heap snapshot, so what keeps these objects reachable is still open.Reproduction
I forced a full GC (
HeapProfiler.collectGarbage) before and after each step:GET /api/orchestration/shell(5 MB body, 43.6 s)Completed responses are released. For aborted requests, the string from
JSON.stringifyand its UTF-8 buffer stay reachable, and they were still held after later GCs.What it isn't
I couldn't reproduce it in a standalone Effect 4.0.4 server. The test served a ~6 MB JSON body after an uninterruptible 1.5 s delay and aborted 10 requests at 500 ms. I ran it with a plain router and with
HttpApischema responses, with and without a recording tracer, and with the global compression middleware T3 installs, on Node 24 and on Node 26. Every variant released everything after GC.So the retention looks specific to T3's server layers around these endpoints, rather than to Effect's HTTP stack. Candidates are the auth middleware,
annotateEnvironmentRequest,traceLocalHandlerWorkand the trace exporter. I haven't narrowed it further.Why it compounds
/api/orchestration/shellreturns every unarchived thread. I have 1,744, of which only 9 are archived. It took a median of 6.4 s earlier, and 44 s once the heap was large.That matches my time-to-abort dropping from 161.7 h, with about a dozen threads, to 9-15 h once I had 1,100+ threads.
Answers to the questions above
mainnow uses effect 4.0.5, but its Node HTTP server and response changes are only URL redaction in logs, so the client-disconnect path looks unchanged.ArrayBufferbacking stores from response encoding, almost all inlarge_object_space. Retainer paths are unknown.Probably separate, from T3's own trace spans
ThreadLiveEventCoalescer).On my side, I'm removing the client timeouts and most of my shell calls, which should stop the growth for me. The server-side retention on client disconnect would remain.
juliusmarminge commentedon Oct 27, 2026
Thanks for the abort reproduction. I followed it on current main 68339a0846 (effect 4.0.5) and on your build, v0.1.0 81493efb71 (effect 4.0.4). I ran v0.1.0 under both Node 24.22.1 and Node 26.8.2. I could not reproduce the retention.
Setup:
t3 servefrom source on a fresh base dir with--inspect, 1 project, and 1,744 unarchived threads created through the server.GET /api/orchestration/shellreturned 2.6–2.8 MB in 0.3–0.4 s, with a bearer token fromt3 auth session issue. I forced a full GC (HeapProfiler.collectGarbage, twice) before each reading.accept-encoding: gzip, br(br response)arrayBuffersstayed at 0.5 MB after every abort phase. Transcript, drivers: seed-threads.mjs, measure-aborts.mjs, measure-bursts.mjs, issue-mcp-token.ts.So the path that keeps the response alive is probably something on your host that this setup lacks. To close the gap:
t3 auth session issue, a browser session cookie, or a pairing token. And the client itself (curl, fetch, your own tool), with the exact command or code that aborts.T3CODE_OTLP_TRACES_URL/T3CODE_OTLP_METRICS_URL/T3CODE_OTLP_LOGS_URL)? If so, roughly what endpoint (local collector or remote)?t3 serve --base-dir <empty dir> --port <free port>on the same host and binary, with your aborting client pointed at that server?The PR discovery sweep and the two never-ending traces you list look like separate issues. #25172 already tracks the PR sync sweep's memory growth.
athammer commentedon Oct 27, 2026
Thanks for running it. I found the retainer, and I think it explains why your runs stayed clean. The leak needs the client's disconnect to be noticed after the handler has returned, while the response is being compressed and sent. Every abort in your runs landed while the handler was still running, so the fiber was interrupted cleanly and answered 499.
Retainer path, from the live process
I read this from the running v0.1.0 server through the inspector, not a snapshot. I used
Runtime.queryObjectsonServerResponse, then the closure ofmakeHandler'scloselistener to reach each request fiber. Names and sizes only.After a forced GC, 9
ServerResponses were alive withwritableEnded: true,writableFinished: falseanddestroyed: true:GET /api/orchestration/threads/:threadId:content-encoding: gzip,content-lengthabout 7.3 MB;endcallback is still queued;interruptible: falsewith an interrupt pending, and is suspended on that callback undertoHandled'ssendResponsecontinuation.GET /api/orchestration/shellwith status 499: their fibers completed. I don't know what keeps these four response objects reachable, and they don't hold a body.The chain for the 200s:
makeHandlerruns each request withFiber.runIn(..., options.scope).sendResponse'smapEagercontinuation, which closes over the original, uncompressed response.Uint8Arraybody: a 54.4M-character JSON string and its 54.4 MB of UTF-8 bytes. The 7.3 MB gzip output is held by the pendingendcall.That's about 116 MB per stuck request, which matches the
large_object_spaceandarrayBuffersgrowth in my earlier comment.Why it happens
In effect 4.0.4,
toHandledsends the response uninterruptibly, including pre-response handlers such as the compression middleware. Suppose the socket'scloseis handled in that window:makeHandlerrequests an interrupt, which is deferred.handleResponsecallsnodeResponse.end(body, cb)on the destroyed response. That's theUint8Arraybranch for 1 MB or more, orRaw.finish, so Node never callscb, and the fiber waits forever.A disconnect during the synchronous end of the handler (
JSON.stringifyof the full snapshot) is only noticed at the next I/O turn, and that is the async zlib call. Here the responses are 54 MB before compression, on a busy event loop, so the window lasts seconds and my client's 15 s timeout lands in it. Your shell responses were 2.7 MB in 0.3 s, so the window was a few milliseconds.I've opened a fix in Effect: Effect-TS/effect#10004.
writeResponsenow skips the write and releases the body when the response is already destroyed. Writes that wait onend's callback also resume when the response closes, which covers a disconnect during the send itself. Its deterministic test is an uninterruptible route that responds after the client's socket closes. On Effect'smain, theRawcase hangs and theUint8Arraycase fails with a content-length mismatch. T3 would pick up the fix with an Effect bump.To reproduce it in T3, request a thread whose snapshot is tens of MB with
GET /api/orchestration/threads/:threadIdandaccept-encoding: gzip. Time a completed request first, then abort a little before the response would arrive. The standalone version is the test in Effect-TS/effect#10004.Your questions
127.0.0.1:3773, HTTP/1.1. The client is Nodefetch(undici,accept-encoding: gzip, deflate) withAbortSignal.timeout(15000). Auth is a bearer token obtained by exchanging at3 auth pairing createcredential.T3CODE_OTLP_*orOTEL_EXPORTER_OTLP_*variables is set, and there are no telemetry settings. Only the localserver.trace.ndjsonis written.Separately, my client was requesting
/api/orchestration/threads/:threadId?turnLimit=1. That route has no query schema, soturnLimitis ignored and the full snapshot comes back, which is why these responses were 54 MB.