Repository navigation
Background service exhausts V8 heap and aborts after ~1-2 days of continuous uptime under sustained session load #12584
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.