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. Current main @ dfbb11bdd still has the long-lived retention paths that fit this shape. I did not find a duplicate.
Related, not the same ticket:
- #5410 / open #9824 — idle desktop Private Bytes from immortal Effect parent spans (~25–30 MB/h with no provider work). That leak is still unfixed on
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. - #12305 (on
main, after 0.0.42) — bound provider event-log serialization. - #12523 (on
main, after 0.0.42) — drop oversized PR diff cache entries. - #12345 — keep tool payloads out of completion queues (merged to
t3code/codex-turn-mapping, not onmain). - #11514 — bound
getThreadProjectionwith paging (open). - #8796 — delete
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, both v8::internal::V8::FatalProcessOutOfMemory on the server thread during Heap::CollectGarbage / AllocateRawWithLightRetrySlowPath:
| 1 | crash 1 2026-09-18 05:48:00Z uptime 48.0h cgroup memory peak 18.4 G |
| 2 | crash 2 2026-09-19 11:35:36Z uptime 29.8h cgroup memory peak 15.4 G |
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, extra node). 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:
Environment= is only T3CODE_HOME and the unit name. No NODE_OPTIONS, no --max-old-space-size. KillMode=mixed + Restart=always is why recovery is ~25s and why the cgroup teardown SIGKILLs in-flight provider/git children. OOMPolicy=continue only 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:
| 1 | context.turns.push({ |
| 2 | id: turnState.turnId, |
| 3 | items: [...turnState.items], |
| 4 | }); |
items is the turn’s tool/content payload. The array is only rewritten on rollback. Codex / Cursor / OpenCode / Grok / Antigravity keep the same kind of Map<ThreadId, …SessionContext>. stopSession deletes 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.
Default idle window is 30 minutes. It skips stopped, anything with activeTurnId, and anything with backgroundLiveness (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.
ThreadBackgroundLiveness is in-memory and only clears on task.completed (terminal status) or session.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_runtime row. 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 use makeDrainableWorker (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 on main.
4. Immortal parent spans (#5410) are still on main.
forkParked still does Effect.forkScoped(effect) with the caller’s span:
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.getThreadProjection still 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
- Not kernel OOM of a child. The stack is V8
FatalProcessOutOfMemoryon the server thread. - Not “raise the heap and we are done.”
--max-old-space-sizeonly delays the abort. The generated unit also has no place to put it without a systemd drop-in. - Not already fixed in a release newer than 0.0.42. #12305 / #12523 help log/cache spikes; they do not bound session maps, reaper skips, unbounded workers, or parent spans.
Suggested direction
Do not start with a larger heap. Bound the process-lifetime sets:
- Drop completed-turn payloads from adapter session maps (or keep a small rolling window). Resume/rollback should read history from the provider store, not from an ever-growing
turns[]. - Land #9824 so parked reactors do not pin startup spans for the life of the service.
- Bound the runtime-event / DrainableWorker queues (drop or coalesce when the consumer is behind; bring #12345 onto
main). - Land #11514 so projection reads cannot materialize a whole thread.
- Land #8796 so deleted threads do not accumulate bindings. Separately: sample
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=mixed will still SIGKILL the cgroup if the main process aborts.
What would help on the next occurrence
The offer of --heapsnapshot-near-heap-limit=1 is the right next artifact. Also useful, if cheap:
process.memoryUsage()/v8.getHeapStatistics()on a 15–30 min timer (heapUsed vs external vs rss).- Counts:
provider_session_runtimebystatus,adapter.listSessions()vs that table, and reaper skip reasons from the server log. - Whether
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:
| 1 | # ~/.config/systemd/user/t3code.service.d/heap.conf |
| 2 | [Service] |
| 3 | Environment=NODE_OPTIONS=--max-old-space-size=8192 --heapsnapshot-near-heap-limit=1 |
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=always is already holding. Filing this is correct; a long-running t3 serve should not hit the V8 ceiling under normal sustained use.