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)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 5 comments · opened Jul 26, 2026

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 = 0kB in 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 comm was 2.1.220, i.e. the CLI binary, not grep/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 comm 2.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 .jsonl stops 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 --resume transcript reification, ~140 MB/s. No resume involved here.
  • #76242 / #78861 — grep-shim OOMs; explicitly ruled out above by controlled replay.

View original on GitHub ↗

3 Comments

amorabito · 1 month ago

Corroborating report from a different surface, same version, same class.

One Claude Code Remote Control session host (the claude.exe --sdk-url ... process that claude remote-control spawns 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:

  • Claude Code 2.1.220 (npm global install, node v20.20.2)
  • Linux 6.8.0-101-generic, x86_64; k8s pod with a 24 GiB cgroup-v2 memory limit
  • The npm CLI auto-updated 2.1.219 to 2.1.220 that morning

Observations:

  • Container working set was flat at 6.0 to 6.7 GiB for 90 minutes (Prometheus, 2-minute step); the next sample read 24.0 GiB, at the limit. Implied sustained rate is roughly 150 MB/s, close to the ~95 MB/s reported here.
  • Kernel OOM dump: Killed process ... (claude.exe) total-vm:21256660kB, anon-rss:19191040kB. 151 tasks in the cgroup; the next-largest was 0.9 GiB.
  • The process wrote nothing to any session transcript during the growth window, so it was idle or initializing, not streaming a turn. That matches the "no tool use, no file reads" description in this issue.
  • Version split in the same pod: four desktop sessions on the separately pinned ccd-cli 2.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.
  • NODE_OPTIONS is stripped by the SDK before spawn, so no V8 heap cap applies; 18 GiB of anon in one node process suggests external/Buffer memory rather than V8 heap.

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.

Axiom89 · 29 days ago

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.220 every 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

  • Claude Code 2.1.220, native installer (~/.local/bin/claude~/.local/share/claude/versions/2.1.220)
  • Ubuntu 24.04.4 LTS, kernel 6.17.0-1019-aws, x86_64, 2 vCPU / 3.8 GiB RAM, swap enabled (2 GiB)
  • Persistent interactive sessions (not -p), each under systemd with MemoryHigh=2G, MemoryMax=2560M; BASH_DEFAULT_TIMEOUT_MS=300000
  • Normal steady-state footprint for these sessions: median ~470 MiB, healthy peak ~800 MiB

Kernel evidence (3 events; hostname, uids and paths redacted)

Relevant rows of each OOM task table (rss/swapents columns in pages):

Event A — 2026-07-29 21:22:05Z, unit A
[ 857594] claude   rss  31839  rss_anon  31146  swapents  42255   <- interactive parent, 124 MiB (survived)
[ 863299] head     rss    275                   swapents      0
[ 863300] 2.1.220  rss 612724  rss_anon 612219  swapents 405472   <- 2.34 GiB RSS + 1.55 GiB swap
kernel: JITWorker invoked oom-killer: ... oom_memcg=/system.slice/<unit-A>
kernel: Memory cgroup out of memory: Killed process 863300 (2.1.220)
        total-vm:4477528kB, anon-rss:2448876kB, file-rss:2020kB

Event B — 2026-07-31 10:19:01Z, unit A
[1110349] bash     rss    730                   swapents     96
[1110370] bash     rss    476                   swapents     96
[1110371] head     rss    366                   swapents      0
[1110372] 2.1.220  rss 600002  rss_anon 599488  swapents 453920   <- 2.29 GiB RSS + 1.73 GiB swap
[1085625] claude   rss  45284  rss_anon  44561  swapents  25176   <- interactive parent, 177 MiB
kernel: claude invoked oom-killer: ... Killed process 1110372 (2.1.220)
        total-vm:5526168kB, anon-rss:2397952kB
kernel: JITWorker invoked oom-killer: ... Killed process 1085625 (claude)
        total-vm:5876480kB, anon-rss:178244kB

Event C — 2026-07-31 12:30:40Z, unit B (different uid, different unit, same host)
[1134705] claude   rss  46977  rss_anon  46230  swapents  36965   <- interactive parent, 183 MiB (survived)
[1137586] bash     rss    825                   swapents     64
[1137608] bash     rss    551                   swapents     64
[1137609] tail     rss    454                   swapents      0
[1137610] 2.1.220  rss 600269  rss_anon 599712  swapents 465824   <- 2.29 GiB RSS + 1.78 GiB swap
kernel: HTTP Client invoked oom-killer: ... Killed process 1137610 (2.1.220)
        total-vm:5526232kB, anon-rss:2398848kB

Three things that may be useful here

1. PID adjacency to the backgrounded pipeline — 3/3. In every event the ballooning 2.1.220 process 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) and bash / 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 via CLAUDE_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 .output file is starved from birth.

  • Event B: backgrounded task bm85t2m41.output created 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.
  • Event C: backgrounded task b3ke05969.output created 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. JITWorker twice, HTTP Client once. 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 has MemoryHigh set — 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=0 on 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.events counters, or the session transcript rows around the auto-background handoff if any of that is useful.

Axiom89 · 29 days ago

Correcting my own comment above — and I think the grep exclusion 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 grep shim 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 comm cannot exclude it

Per #76056, the Bash tool injects a grep shell function that re-executes the CLI binary as embedded ugrep:

ARGV0=ugrep $CLAUDE_CODE_EXECPATH -G …

Linux derives comm from the executed file, not from argv[0]. $CLAUDE_CODE_EXECPATH is the versioned binary. So a shimmed grep appears as comm=2.1.220 in a kernel dump and as ugrep in ps aux.

That means comm=2.1.220 is 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 -o patterns piped into head/tail:

2026-07-29 19:05:38Z  (OOM 21:22:05Z)
  grep -oE  "^[0-9]{2}:[0-9]{2} \| discovery \|[^|]*\|[^|]*\|[^|]*" <file>.md | tail -10
  grep -oiE "[^|]{0,80}discovery[^|]{0,80}ACK[^|]{0,60}"            <file>.md | head -10

2026-07-31 09:06:32Z  (OOM 10:19:01Z)
  grep -n "05:00Z" <file>.md | grep -io ".\{0,80\}\(ACK\|CLOSED\|closure\).\{0,60\}" | head -10

Against the kernel task tables I posted above:

[ 863299] head     ...   <- pipeline sibling
[ 863300] 2.1.220  ...   <- 2.34 GiB, born next PID

[1110371] head     ...
[1110372] 2.1.220  ...   <- 2.29 GiB, born next PID

The version-named process is born one PID after the head of the very pipeline containing the grep. 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 shows tail rather 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:

  1. "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." — 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.
  1. "The OOM victim's comm was 2.1.220, i.e. the CLI binary, not grep/ugrep." — under the shim the CLI binary is the grep. comm=2.1.220 with argv[0]=ugrep is 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 -o pattern — 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,args rather than comm alone. Under the shim, args starts with ugrep; a supervisor worker carries --bg-spare or --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/-oiE with repeat quantifiers ({0,N}, *) as a memory hazard inside Bash tool calls and use fixed-string/anchored matches or python3 for extraction.

Showing cached comments. Read the full discussion on GitHub ↗