Single background subagent (run_in_background, no tool use) balloons to 9.5 GiB in ~100 s → global kernel OOM (2.1.220, Linux, no swap)
Summary
A single, non-nested background subagent (Task tool, run_in_background: true) spawned by an interactive CLI session grew from ~0 to 9.5 GiB anon-RSS in at most 103 seconds and triggered a global kernel OOM on a 15.6 GiB Linux host. The interactive parent process stayed at ~452 MiB throughout and survived the kill.
Sustained allocation rate ≈ 95 MB/s.
The subagent's prompt was a pure-reasoning task explicitly instructed to read no files and use no tools, so there was no large tool output, no file read, and no transcript resume involved.
Environment
- Claude Code 2.1.220, native installer (
~/.local/bin/claude→~/.local/share/claude/versions/2.1.220) - Ubuntu 24.04.4 LTS, kernel
6.8.0-117-generic, x86_64 - 15.6 GiB RAM, swap = 0 (
Total swap = 0kBin the OOM dump) - Interactive session running inside a tmux 3.4 pane. tmux places each pane in a transient systemd user scope (
tmux-spawn-<uuid>.scope), which is what makes the cgroup attribution below available.
Timeline (relative to subagent launch)
| t | Event |
|---|---|
| t+0 s | Background subagent launched via Task tool (run_in_background: true, subagent_type: general-purpose, model opus). Pure-reasoning prompt, no file reads. |
| t+14 s | The parent session (not the subagent) began a foreground Bash grep over a 275 MB file. Ruled out below. |
| t+103 s | Kernel global OOM. Victim: a 2.1.220 process at 9.5 GiB anon-RSS. |
| t+103 s | The subagent's own output .jsonl stops writing at the same millisecond. |
| t+103.1 s | Parent process (comm claude, 452 MiB) still alive, writes 3 more records, then exits cleanly. |
Evidence (verbatim kernel log; hostname, UUIDs and paths redacted)
kernel: 2.1.220 invoked oom-killer: gfp_mask=0x140dca(GFP_HIGHUSER_MOVABLE|__GFP_COMP|__GFP_ZERO), order=0, oom_score_adj=100
kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=user.slice,mems_allowed=0,global_oom,
task_memcg=/user.slice/user-1000.slice/user@1000.service/app.slice/tmux-spawn-<uuid>.scope,task=2.1.220,pid=<PID_W>,uid=1000
kernel: Out of memory: Killed process <PID_W> (2.1.220) total-vm:10769744kB, anon-rss:9956352kB,
file-rss:2048kB, shmem-rss:0kB, UID:1000 pgtables:19816kB oom_score_adj:100
Relevant rows of the same OOM task table (rss column is in pages):
[ pid ] uid tgid total_vm rss rss_anon rss_file rss_shmem pgtables_bytes swapents oom_score_adj name
[<PID_L>] 1000 332836 1024 480 544 0 126976 0 100 2.1.220 <- launcher, 4 MiB
[<PID_W>] 1000 2692436 2489600 2489088 512 0 20291584 0 100 2.1.220 <- worker, 9.5 GiB
[<PID_P>] 1000 1503502 115726 114370 1356 0 2109440 0 100 claude <- interactive parent, 452 MiB (survived)
Note the comm values: child CLI processes appear as 2.1.220 because the native installer's binary file is literally named after the version, while the interactive parent launched through the claude symlink appears as claude. This is the same retitling signature reported in #76185.
Why the concurrent grep is not the cause
The parent's foreground grep -aoE '[^"]{0,70}timeout[^"]{0,70}' over a 275 MB file is a plausible-looking suspect (bounded repeats + -o, cf. #76242 / #78861), so it was tested rather than assumed:
- Controlled replay of the identical command under
/usr/bin/time -v: 3 min 03 s wall, peak RSS 25.7 MB — not 9.5 GiB. - It runs as its own process (separate PID, ~23 MB RSS observed during replay), not inside the CLI process.
- The OOM victim's
commwas2.1.220, i.e. the CLI binary, notgrep/ugrep.
So the known grep-shim memory issues are not what happened here.
Attribution confidence — stated honestly
The kernel OOM dump does not carry PPID, so identifying the victim as the background subagent is inferential, not proven. The converging signals:
- adjacent PID pair in the same cgroup:
<PID_L>(4 MiB, launcher) /<PID_W>(9.5 GiB, worker), both comm2.1.220; - the PID range corresponds to a spawn at t+0;
oom_score_adj=100, matching the other CLI processes;- the subagent's output
.jsonlstops at the OOM millisecond; - the interactive parent is separately present in the same table and survived.
I would rather flag this as high-confidence-inferential than assert it as established.
Reproduction
Not reproduced — I want to be straight about this rather than invent a repro. This is a single occurrence (one global_oom in the host journal over the preceding week), and subsequent comparable sessions have not reproduced it. The contextual conditions were:
- interactive CLI session, ~65 min old at the time, running in a tmux pane on Linux;
- exactly one background subagent in flight (
run_in_background: true, general-purpose, opus), no nesting, no fan-out; - that subagent's task was pure reasoning — explicitly told not to read files or use tools — so it should have had a small, roughly constant working set;
- the parent was concurrently running one foreground Bash command;
- host had zero swap, so there was no reclaim runway to absorb a spike: the allocation went straight to global OOM.
Given the rate (~95 MB/s) and the absence of any large input on the subagent's side, this looks like unbounded allocation in the child CLI process itself rather than buffering of anything the task produced.
Host-side amplification (not a CLI bug — context for the impact)
Worth recording because it turned a recoverable child-process kill into a lost session: systemd's default OOMPolicy=stop applies to the pane's transient scope, so when the kernel killed one process in that cgroup, systemd stopped the whole scope — SIGTERM to the cgroup, then SIGKILL of the pane's shell after TimeoutStopSec=90s, destroying the tmux session. The CLI process itself was an innocent bystander in that second step.
Mitigation currently in place
Per-pane cgroup bounds on the transient scopes, so a leak dies inside its own cgroup instead of starving the host:
[Scope]
OOMPolicy=continue
MemoryHigh=5G
MemoryMax=6G
Verified end-to-end on a disposable pane: the runaway child is killed by the cgroup OOM killer, and the pane, its shell and the session all survive. 4 GiB of zram swap was also added to give the kernel reclaim runway. This is the same class of workaround as the one described in #76185.
What would help from the product side
- A memory ceiling (and/or recycling) for background subagent child processes, so one child cannot exhaust the host.
- Memory-aware backpressure on subagent spawns, keyed on observed RSS rather than only CPU-count heuristics.
- If a child exceeds a ceiling, fail that subagent with a clear error rather than letting the kernel choose the victim — on a host without swap the kernel's choice can land anywhere.
Related issues
- #76185 — same comm-retitle signature, Linux, kernel OOM, and the same cgroup workaround; but the trigger there is a long-running background Bash task with growth over 15–25 min, whereas this is a background subagent with no tool use growing in ~100 s.
- #74001 — multi-subagent load on Linux, subagent CLI processes ~6.5 GB RSS each (2.1.187). This report is a single, non-nested subagent.
- #74035 — deeply-nested subagent fan-out, 26 GB (2.1.200). No nesting here.
- #79196 — headless
--resumetranscript reification, ~140 MB/s. No resume involved here. - #76242 / #78861 — grep-shim OOMs; explicitly ruled out above by controlled replay.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Corroborating report from a different surface, same version, same class.
One Claude Code Remote Control session host (the
claude.exe --sdk-url ...process thatclaude remote-controlspawns per phone session) grew from a flat baseline to 18.3 GiB anon-RSS in under two minutes while completely idle, then took out our container.Environment:
Observations:
Killed process ... (claude.exe) total-vm:21256660kB, anon-rss:19191040kB. 151 tasks in the cgroup; the next-largest was 0.9 GiB.ccd-cli2.1.219 binary stayed at 0.2 to 0.3 GiB throughout; only the 2.1.220 npm-CLI process class ballooned. Six prior days of the identical Remote Control workload on 2.1.219 had no balloons.Since your report is a background subagent and ours is a Remote Control session host, the defect looks process-level in 2.1.220 rather than tied to either feature.
Happy to share the full OOM task dump or the cgroup time series if useful.
Fourth surface, same class — and our task tables happen to carry the PID-adjacency signal that a single-event dump can't, which may help with the attribution caveat in the original report.
Summary: two independent long-lived interactive CLI sessions (separate uids, separate systemd units, one host), each in its own 2.5 GiB cgroup. Three kills in three days on 2.1.220, victim
comm=2.1.220every time. Trigger surface is neither a Task subagent nor Remote Control: it is the Bash-tool 300 s timeout auto-background path, after which the session sits idle-polling the backgrounded item — i.e. the same "idle while a background work item is outstanding" shape as #76185, on a third distinct spawn path.Environment
~/.local/bin/claude→~/.local/share/claude/versions/2.1.220)6.17.0-1019-aws, x86_64, 2 vCPU / 3.8 GiB RAM, swap enabled (2 GiB)-p), each undersystemdwithMemoryHigh=2G,MemoryMax=2560M;BASH_DEFAULT_TIMEOUT_MS=300000Kernel evidence (3 events; hostname, uids and paths redacted)
Relevant rows of each OOM task table (
rss/swapentscolumns in pages):Three things that may be useful here
1. PID adjacency to the backgrounded pipeline — 3/3. In every event the ballooning
2.1.220process is born with a PID immediately following the processes of the very Bash pipeline that was auto-backgrounded:bash / bash / head / 2.1.220(A, B) andbash / bash / tail / 2.1.220(C). That is a tighter binding than the launcher/worker adjacency in the original report — the version-named child here is spawned inside the user command's pipeline, not alongside it. #76056 documents the CLI re-execing its own binary as a tool shim viaCLAUDE_CODE_EXECPATH, which would produce exactly this ordering; I am flagging that as the plausible mechanism, not asserting it, since the dump carries no PPID.2. Timing is anchored to the auto-background handoff, and the
.outputfile is starved from birth.bm85t2m41.outputcreated 09:00:33.49Z and still 0 bytes at the kill — it never produced a single byte. OOM at 10:19:01Z, 78 min later.b3ke05969.outputcreated 11:31:35.18Z, 198 bytes total; the session's last output is the same second. OOM at 12:30:40Z, 59 min later.3. The allocating thread at kill time.
JITWorkertwice,HTTP Clientonce. The dump does not let me bind the invoking thread to a PID, so this is suggestive only — but it is consistent with @amorabito's read above that 18 GiB of anon in one process points at external/Buffer memory rather than V8 heap.Why our growth rate looks 100× slower — and why that probably isn't a different bug
Ours is ~2.3 GiB over 59–78 min (≈0.6 MB/s) against the ~95 MB/s here and ~150 MB/s reported by @amorabito. I do not think that is a separate phenomenon: our cgroup sets
MemoryHigh=2G, which throttles the allocator and forces reclaim above 2 GiB, and swap was enabled and uncapped, so the process was throttled and paged out rather than free-running. The result was a 60–80 minute throttled zombie instead of a 100-second sprint. The ceiling-seeking behaviour is the same; only the brake differs. Worth noting for anyone whose cgroup hasMemoryHighset — you may be hosting this bug for an hour without noticing, with the main session degrading the whole time (our sessions logged inexplicable >300 s timeouts on trivial commands during the ramp, which we initially misdiagnosed as the cause rather than a symptom of the starved cgroup).Version evidence
The same workload on the same host ran 2.1.207 → 2.1.219 for weeks with zero OOM events in the journal. 2.1.220 was installed Jul 25; full workload cadence resumed Jul 27–28; first OOM Jul 29. That is consistent with @amorabito's 2.1.219-clean / 2.1.220-bad split, though it is weaker evidence than theirs since ours is a cadence coincidence rather than a side-by-side pin.
Honest caveat on the "fix": we rolled both units back to 2.1.214 on Jul 31 ~13:45Z and have had zero events since, but that is only ~20 hours and our scheduled workload has been paused since Jul 31 12:40Z for this investigation. So our post-rollback quiet period is not yet a clean test and should not be cited as evidence that 2.1.214 is immune — particularly since #76185 puts this same version-named-child class on 2.1.205, well below the 2.1.216–2.1.218 background-session rework.
Containment that helped
MemorySwapMax=0on the unit. With swap available the runaway survives as a throttled zombie for over an hour while starving the real session; with swap denied it hits the ceiling and dies contained in about two minutes. If you are running the CLI under a memory-capped unit, capping swap separately turns a long silent degradation into a fast, legible failure.Happy to provide the full unredacted task tables, the
memory.eventscounters, or the session transcript rows around the auto-background handoff if any of that is useful.Correcting my own comment above — and I think the
grepexclusion in the opening report inverts, twice.Since posting I recovered the exact commands behind two of our three events. They change my attribution: ours are not the background-task path. They are the
grepshim from #76056, and the reasoning that ruled the shim out here appears to rest on two properties of that shim that are genuinely counterintuitive.The shim, and why
commcannot exclude itPer #76056, the Bash tool injects a
grepshell function that re-executes the CLI binary as embedded ugrep:Linux derives
commfrom the executed file, not fromargv[0].$CLAUDE_CODE_EXECPATHis the versioned binary. So a shimmed grep appears ascomm=2.1.220in a kernel dump and asugrepinps aux.That means
comm=2.1.220is exactly what a grep looks like in an OOM dump. It separates CLI-self-spawn from your interactive session, but it does not separate CLI internals from a shimmed grep.Our two recoverable events
Both are bounded-repeat
-opatterns piped intohead/tail:Against the kernel task tables I posted above:
The version-named process is born one PID after the
headof the very pipeline containing thegrep. Under the shim that is precisely where it belongs — it is the grep. Our third event (the one on the second unit) I cannot attribute: its transcript row was never written, and its table showstailrather than a grep, so it may be a starved-cgroup victim rather than a second instance.Where I think the exclusion here goes wrong
Two arguments in the opening report, both of which I read as inverting under the shim:
/usr/bin/time -v: 3 min 03 s wall, peak RSS 25.7 MB — not 9.5 GiB." — a replay in a normal shell has no shim function defined, so it exercised system grep. The shim only exists inside a Bash tool call. A low peak RSS from a plain-shell replay is expected either way and cannot exculpate.commwas2.1.220, i.e. the CLI binary, notgrep/ugrep." — under the shim the CLI binary is the grep.comm=2.1.220withargv[0]=ugrepis the shim's expected signature, so this reads as confirmation rather than exclusion.And the command itself fits:
grep -aoE '[^"]{0,70}timeout[^"]{0,70}'over a 275 MB file is a bounded-repeat-opattern — the exact shape #76056 reports ballooning to 8 GB in 45 s on a 263 KB file. Your timeline has it starting at t+14 s with the OOM at t+103 s, i.e. 89 seconds of that grep, which is the right order of magnitude for 9.5 GiB. The adjacent-PID<PID_L>(4 MiB) /<PID_W>(9.5 GiB) pair you read as launcher/worker is also consistent with a shim invocation and its child.None of that is proof — the dump carries no PPID for either of us, and your background subagent remains a live candidate. But I don't think the grep can currently be considered excluded, and it is the cheaper hypothesis to test.
What this does not explain
@amorabito's Remote Control case: an idle process, no Bash tool call in the growth window, and a clean 2.1.219/2.1.220 bisect. Nothing shim-shaped there. So this thread may be carrying two different defects that share a
comm— which would also explain why the version correlation is strong for that report and weak for ours (the shim predates 2.1.220 by many versions; our own workload had run the same grep patterns for weeks across 2.1.207–2.1.219).Cheap disambiguation
For anyone reproducing: capture
ps -o pid,ppid,comm,argsrather thancommalone. Under the shim,argsstarts withugrep; a supervisor worker carries--bg-spareor--bg-pty-host; a background session worker carries neither. That one column separates hypotheses that a kernel dump cannot.Practical mitigation on our side, independent of version: treat
grep -o/-oE/-oiEwith repeat quantifiers ({0,N},*) as a memory hazard inside Bash tool calls and use fixed-string/anchored matches orpython3for extraction.