[BUG] Memory leak causes repeated OOM kills in v2.1.224 (14.5GB and 21.3GB anon-rss, two incidents same day)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code (was on 2.1.224 at time of incidents)
What's Wrong?
Claude Code (native install) was killed by the Linux OOM killer twice within the same session/day, consuming 14.5GB and 21.3GB of anonymous RSS respectively before being killed. This matches a long-running pattern of memory leak reports on this repo (#4953, #21403, #22042, #29576, #56335) but I could not find an existing report specifically for 2.1.224.
Environment
- Claude Code version: 2.1.224
- Install method: native
- Platform: Fedora Linux (linux-x64)
- Working directory at time of crash: agrodesk-v3 project (~50k line TypeScript/Supabase codebase)
Evidence (dmesg / journalctl -k)
Incident 1:
Out of memory: Killed process 1369580 (2.1.224) total-vm:14984888kB, anon-rss:14528788kB, file-rss:12kB, shmem-rss:0kB, UID:1000 pgtables:28776kB oom_score_adj:200
Incident 2:
Out of memory: Killed process 1404272 (2.1.224) total-vm:23374392kB, anon-rss:21333220kB, file-rss:16kB, shmem-rss:0kB, UID:1000 pgtables:42104kB oom_score_adj:200
Both incidents show the process running under the binary version string as process name (2.1.224), with oom_score_adj:200, making it a preferred OOM-kill target once RSS grows into this range.
Expected Behavior
Claude Code should not grow unbounded in memory during a normal working session. Memory usage should stay in a reasonable range (a few GB at most) even during long sessions with many file reads/tool calls, as noted in similar prior reports.
Actual Behavior
Process RSS grew to 14.5GB and 21.3GB respectively before being killed by the kernel OOM killer, on two separate occasions in the same day, both while working normally in a single project directory.
Additional Context
- No unusual custom plugins/hooks in use beyond standard config.
- Switching the update channel to
stable(currently on 2.1.220) as a mitigation; have not yet confirmed whether the leak reproduces on this version. - Given the number of similar prior reports across many versions (#4953 at 120GB+, #21403 at 15-17GB, #22042 at 7.5GB, #29576 at 6.4GB), this appears to be a recurring, unresolved memory management issue in Claude Code rather than an isolated regression.
What Should Happen?
Both incidents show the process running under the binary version string as process name (2.1.224), with oom_score_adj:200, making it a preferred OOM-kill target once RSS grows into this range.
Expected Behavior
Claude Code should not grow unbounded in memory during a normal working session. Memory usage should stay in a reasonable range (a few GB at most) even during long sessions with many file reads/tool calls, as noted in similar prior reports.
Actual Behavior
Process RSS grew to 14.5GB and 21.3GB respectively before being killed by the kernel OOM killer, on two separate occasions in the same day, both while working normally in a single project directory.
Additional Context
- No unusual custom plugins/hooks in use beyond standard config.
- Switching the update channel to
stable(currently on 2.1.220) as a mitigation; have not yet confirmed whether the leak reproduces on this version. - Given the number of similar prior reports across many versions (#4953 at 120GB+, #21403 at 15-17GB, #22042 at 7.5GB, #29576 at 6.4GB), this appears to be a recurring, unresolved memory management issue in Claude Code rather than an isolated regression.
Error Messages/Logs
Steps to Reproduce
Steps to Reproduce
No minimal deterministic reproduction found. The leak occurs during normal extended use — no specific trigger action identified. Observed pattern:
- Start a Claude Code session in a large TypeScript/Supabase project (~50k LOC, agrodesk-v3)
- Work normally for an extended period (multiple file reads, edits, tool calls)
- Memory usage grows unbounded over the session
- Process is eventually OOM-killed by the kernel (observed twice same day, at 14.5GB and 21.3GB anon-rss)
This matches the pattern described in #21403 and #4953, where the leak is also reported as gradual and tied to session length/tool call volume rather than a single reproducible action.
Claude Model
None
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.224
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
_No response_
Showing cached comments. Read the full discussion on GitHub ↗
6 Comments
Same signature here, with a deterministic partial reproduction and a version-correlation datapoint that may help narrow it.
Environment: native Linux build (the ~297 MB binary embedding Bun 1.4.0), Ubuntu, 15 GiB RAM host, long agentic sessions (background subagents, many tool calls). Kernel journal shows 13 OOM kills of Claude Code since Jul 16, victims = comm
2.1.210/211/212/220/221/224/224/224/224/225/226, each at 5–13 GiB anon-rss withoom_score_adj:200. Active sessions grow ~120–165 MB/min and die in 1.5–2 h; resuming an already-large conversation died in as little as 17 minutes.Reproducible on demand (linear, version-flat): every byte of Bash-tool child stdout is retained ~2.1×, never reclaimed. Minimal repro (headless, cheap — the tool results are truncated before reaching the model, so almost no tokens):
Dose-response measured in capped scopes: 300 MB piped → ×1.67 · 600 MB → ×2.20 · 1800 MB → ×2.06 (peak 3.8 GB in ~40 s). The instance's on-disk transcript stays ~3 MB (truncated results) while the process holds gigabytes — the retention is on the raw pre-truncation pipe bytes, roughly two copies.
This repro is version-flat — identical peaks on 2.1.219 and 2.1.226 — so it is not, by itself, the whole story for the catastrophic real-workload deaths (their tool traffic is tens of MB, not GB). But the real-workload kills correlate hard with version: the same convergence workloads (same conversation, resumed across weeks) ran Jul 18–Aug 5 on 2.1.213–219 with zero kills, then died 8 times in 3 days once 2.1.220+ landed; two 2.1.217 instances on a second machine have zero kills to date. Something additional seems to have landed around 2.1.220. (The 2.1.221 changelog's memory mitigations — the 16 MB edit-cache bound, the resume-with-background-agents fix — did not stop it: 221, 224, 225 and 226 all died here.)
Refuted locally as the amplifier (one-variable probes in capped instances): streamed model output (4,000 lines, flat), 2 parallel Task/Agent subagents at small tool volume, headless
--resumeof a 13.4 MB transcript (-perrors "Prompt is too long", peak 469 MB), interactive TUI resume of the same (peak 565 MB), MCP servers (tiny RSS in every kill's task dump), transcript size on disk, session-env hook loops (#56335's mechanism — 18 dirs here), superlinear volume retention.Mitigation we applied: pin to 2.1.219 +
DISABLE_AUTOUPDATER=1, and keep large command output redirected to files instead of through tool results. Happy to provide the full kernel task dumps, the per-run CSVs, or to run instrumented builds/env-flag experiments on the affected machine.Correcting my comment above — the process that dies is the SUBAGENT, not the main session. This also invalidates the version-window claim I made, so please disregard that part.
I pinned to 2.1.219 and kept an external sampler on the main agent's pid. It got OOM-killed again — but the sampler shows the main agent was never the one growing. From the kernel's task dump for that kill:
| process | pid | comm | RSS |
|---|---|---|---|
| VICTIM | 2684085 |
2.1.219| 13 101 MB || main agent (survived) | 2553031 |
claude| 318 MB || MCP servers | 2553159/61 |
node| 53–57 MB || sibling forks of the victim | 2684083/84 |
2.1.219| 2 MB each |The main session peaked at 480 MB anon over its whole life and never grew. The 13 GB process was a separate process spawned for a subagent (Agent/Task tool), exec'd via the versioned binary path — which is exactly why its
commis the version string.That re-reads every kill I reported earlier. In all 13, the giant was
comm=2.1.2XXand there was always a smallcomm=claudeprocess alongside. I had assumed the small one was an unrelated older session; it was the main agent, and the giant was its subagent. So:The operator I work with reached the same conclusion independently from the outside: no OOM kill here has ever occurred without subagents running, and every kill has had them.
Suggested repro shape (I have not minimised this yet): run a task that spawns two parallel subagents which each read a large diff and several large files in full, and watch the child
claudeprocesses rather than the parent — e.g.watch 'ps -eo pid,rss,comm --sort=-rss | head'. The parent stays flat; a child climbs.If it helps, the same volume-based reproducer from my previous comment run inside a subagent rather than the main session should show the retention landing on the child process. Happy to run instrumented builds or specific experiments on this machine.
I also had sub-agents, but the main process died as well. By the way, switching to stable solved the problem.
Second correction — and this one settles the version question: it is NOT version-specific, and pinning does not help.
In my previous comments I reported a version correlation (kills on 2.1.220+, "quiet" on ≤219) and pinned to an older build as mitigation. That was wrong — an artifact of a small sample and a probabilistic trigger. New evidence:
I pinned to 2.1.217 (my oldest known-good) with auto-update disabled, and ran the exact workload (a convergence round spawning review+hunt subagents over a ~30-file diff) with an external sampler on every claude-family process. Two rounds were clean. The third round's hunt subagent — a separate
comm=2.1.217process, pid 2971073 — climbed to 7 GB in 31 s, then 13.4 GB, and the kernel OOM-killed it:So, corrected conclusions:
comm=2.1.217— the pinned version, verified against the versions directory, not a newer build. Pinning does not help.Minimal repro shape (watch the child process, not the parent): run a task that dispatches 1–2 subagents each instructed to read a large diff and several large files in full, e.g.
The parent (
comm=claude) stays flat; a child worker (comm=<version>) climbs to OOM on the unlucky run. Growth rate observed: ~200 MB/s once it starts.Workaround that actually works (since pinning does not): cap the subagent, not the version. The main agent is
comm=claudeand every subagent/worker iscomm=<version-string>, so an external guard can kill just acomm=<version>process that crosses a memory ceiling, sparing the main agent and host; the failed subagent call is then retried. A cgroupMemoryMaxaround subagent spawns would be the clean equivalent if Claude Code exposed a spawn hook.Happy to run instrumented builds or capture full task-dumps on this machine.
You were right about the pinning, it didnt help. My SO says:
● ▎ TL;DR — the runaway comm=<version> process is not a subagent: it is Claude Code's own binary re-exec'd as the embedded ugrep 7.5.0 by the Bash tool's grep shim, whose regex compilation blows up exponentially
▎ (memory doubles per +1) on any pattern with two bounded repeats like [^.]{0,110}…[^.]{0,90}, allocating ~100–200 MB/s until the kernel kills it — reproducible on an empty input file, in every version, which is
▎ why pinning never helped.
----------------------
Third data point — same signature, but I traced the runaway to a concrete cause: the
grepshell shim (embedded ugrep 7.5.0), not a subagent.I hit an identical-looking OOM today and captured the full chain. Every observable in my case matches the report above —
comm=<version>child,oom_score_adj=200, main agent flat and surviving, ~100–200 MB/s growth, version-independent — but the runaway was not a subagent worker. It wasgrepfrom a single Bash tool call, executed as ugrep by the shell-snapshot shim.Environment: Claude Code 2.1.220, Fedora, kernel 7.1.6-201.fc44.x86_64, 32 GB RAM.
---
What the kernel recorded
Task dump, relevant rows (rss in pages, last column = name):
The adjacent PIDs are the giveaway. 937698/937699/937700 are one shell pipeline:
sort,head, and the runaway. That pipeline appears exactly once in my session transcript:A single Bash tool call. No subagent was running.
Why it shows up as
comm=<version>The Bash tool's shell snapshot (
~/.claude/shell-snapshots/snapshot-bash-*.sh, lines ~178–208) shadowsgrepandfindwith functions that re-exec the Claude Code binary itself:CLAUDE_CODE_EXECPATHis~/.local/share/claude/versions/<version>, so the kernel setscommto the version string, whileexec -asetsargv[0]tougrep. This means:Anyone can confirm this in one second with a completely harmless grep from the Bash tool:
A sampler built on
ps -eo pid,rss,comm(as in the comment above) cannot distinguish these from a subagent worker — the distinguishing field isargs, notcomm. I suspect the "hunt subagent" that ran away was in fact agrepissued by that subagent: a subagent told to read a large diff and many files greps a lot.The actual bug: exponential DFA construction in the embedded ugrep
It is not a slow leak tied to tool volume. It is regex compilation, and it happens with no input at all — an empty file reproduces it. The embedded ugrep is 7.5.0 (
ugrep 7.5.0 x86_64-pc-linux-gnu +sse2; -P:pcre2jit).Measured on a 0-byte input file, pattern
[^.]{0,N}(a|b)[^.]{0,N}\., invoked exactly as the shim does:| N (both sides) | time | max RSS |
|---|---|---|
| 8 | 0.07 s | 16 MB |
| 10 | 0.14 s | 29 MB |
| 11 | 0.26 s | 48 MB |
| 12 | 0.51 s | 90 MB |
| 13 | 1.11 s | 179 MB |
| 14 | 2.51 s | 359 MB |
| 16 | >8 s (capped) | 1.05 GB |
| 20 | >8 s (capped) | 1.31 GB |
Time and memory roughly double per +1 in the interval bound. At the bounds in my real command (
{0,110}and{0,90}) it simply allocates until the machine dies — mine reached 23.7 GB (19.5 RSS + 4.2 swapped) in ~210 s, i.e. ~110 MB/s, matching the ~200 MB/s reported above.Notes on the trigger:
[^.]{0,110}(a|b)\.(one side only) finishes in 0.02 s / 10 MB.[^.]{0,110}a[^.]{0,90}\.explodes too.Same pattern, same file, other engines:
| engine | result |
|---|---|
| embedded ugrep 7.5.0 (what
grepactually runs) | never terminates, ~110–165 MB/s until OOM || GNU grep 3.12 (
/usr/bin/grep) | 2.84 s / 284 MB, correct output || embedded ripgrep 14.1.1 (the built-in Grep tool's engine) | 0.02 s / 14 MB, correct output |
So the built-in Grep tool is unaffected; only the Bash-tool
grepshim is.This also explains the "probabilistic trigger" without invoking sample size: it is 100% deterministic given a
{n,m}-style bounded repeat in a Bashgrep, and 0% without one. It has nothing to do with how much the agent reads — only with whether the model happens to write such a regex. And it explains version-independence: ugrep is compiled into every build, so pinning cannot help.Two things that turned a hung command into a dead machine
pkill -f "grep -oE"cannot match it. Because ofexec -a ugrep, the runaway's cmdline isugrep -G --ignore-files --hidden -I --exclude-dir=... -oE ...— the string "grep -oE" never appears. The session believed it had killed the process; nothing had. The correct kill ispkill -x <version-string>or matching onugrep.Finally, the session did not die because the CLI was killed — the kernel only killed the ugrep child. systemd tore down the whole terminal scope afterwards:
That matches the "session dies from the swap storm around the runaway" observation.
How to check whether the earlier reports are the same bug
Cheap and decisive, in order:
argsto the sampler:ps -eo pid,rss,comm,args --sort=-rss | head. If the runaway'sargv[0]isugrep(orbfs), it is a shim invocation from a Bash tool call, not an agent worker.sort,head,bash, ...). That is what settled it for me.grep -c '{0,' ~/.claude/projects/<slug>/<session>.jsonl, then look at the Bash commands around the kill timestamp.{0,16}allocates 1 GB in 8 s, the cause is confirmed independently of any agent.Suggested fixes (in order of value)
-E/-opatterns containing{n,m}to the systemgrep.systemd-run --scope -p MemoryMax=…where available).grepis not GNU grep is invisible until something behaves differently. It is also a correctness issue, not just performance — the same pattern returns matches with GNU grep and hangs forever with ugrep.Workaround for users today:
command grep//usr/bin/grepbypasses the shim (shell functions are not inherited throughexec, sotimeout 5 grep …also runs the real GNU grep), and the built-in Grep tool is safe.A workaround that actually prevents these OOMs: block the pattern class before it runs.
Short recap of the cause, for anyone landing here: inside Claude Code's Bash tool
grepis not GNU grep — the shell snapshot shadows it with a function that re-execs the Claude Code binary as its embedded ugrep 7.5.0 (exec -a ugrep "$CLAUDE_CODE_EXECPATH" …), which is why the runaway shows up ascomm=<version>. That engine's regex compilation blows up exponentially when a pattern contains two bounded repeats ({n,m}) — memory and time roughly double per +1 in the bound, and it reproduces on a 0-byte input file:At the bounds a model naturally writes for "N characters of context around a keyword" (
[^.]{0,110}(a|b)[^.]{0,90}\.) it never terminates and allocates ~100–200 MB/s until the kernel OOM-kills it.The model is the one writing these patterns, so a written instruction ("don't use two bounded repeats") is not enforcement — it has to be a gate. A
PreToolUsehook onBashdoes it.~/.claude/settings.json(merge with your existing settings):~/.claude/hooks/block-regex-bomb.py:How it decides. It denies only when a shim-routed
grepcarries two or more{n,m}ranges with a bound ≥ 10. Explicit bypasses (/usr/bin/grep,command grep) pass through, so the model always has an escape hatch — and the deny message tells it which one to take, including the three ways GNU grep is not a drop-in replacement (\dunsupported, no .gitignore/hidden filtering on-r, no-I). The thresholds come from the measurements above: one bounded range is harmless, and{0,10}twice costs 29 MB and terminates.Tested against the three commands that actually killed sessions on my machine (all blocked) and against commands that must not be blocked:
grep -rn "word" dir/,grep -oE "<title>[^<]*</title>", a single{0,200}range,[0-9]{3,4}-style small bounds,/usr/bin/grepandcommand grepcarrying a bomb pattern, and a Python heredoc containing{0,110}. Malformed stdin and non-Bash payloads pass through. Verified live — the hook took effect immediately, no restart needed, and normal greps keep working.If you don't want a hook, the manual version of the same rule: never put two
{n,m}repeats in a Bash-toolgrep; use/usr/bin/grep, the built-in Grep tool, or Python for context extraction. And if one does get away from you,pkill -f "grep …"will not find it —exec -a ugreprewritesargv[0], so the cmdline never contains "grep". Usepkill -x <version-string>(e.g.pkill -x 2.1.220) orpkill -f ugrep.