Bundled ugrep runs with no memory limit or timeout — model-generated regex consumed 13.6 GB and thrashed the host
Bundled ugrep runs with no memory limit or timeout — a model-generated regex consumed 13.6 GB and thrashed the host
Summary
The Grep tool re-execs the Claude Code binary as ugrep (argv[0]="ugrep") with no RLIMIT_AS, no RLIMIT_CPU, and no wall-clock timeout. A pathological pattern generated by the model — not by the user — grew to 2.5 GB RSS + 11.1 GB swap = 13.6 GB on a 15 GB machine and ran for 11+ minutes before being killed manually. It saturated zram, spilled 9 GB into the disk swapfile, and drove the whole desktop into swap thrash (memory pressure full avg60 = 21%, load average 13).
The search was never going to complete. There is no mechanism in Claude Code that would have stopped it.
Environment
- Claude Code 2.1.228 (spawning session) / 2.1.229 (current)
- Fedora Linux 44 Workstation, kernel 7.1.5-201.fc44.x86_64
- 15 GiB RAM, zram (zstd, 15.3 G) + 16 G disk swapfile
The invocation
ugrep -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn \
--exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl \
-oiE '[a-z0-9/_.-]{0,60}e556[a-z0-9/_.-]{0,60}' f55.html
Target file: 480 KB, 11,063 lines, longest line 3,065 chars. Trivially small.
Why it blows up
ugrep compiles to a DFA rather than backtracking. Counted repetitions are expanded before subset construction, so {0,60} becomes 60 copies of the character class. The class has ~40 members, and the pattern contains two such repetitions. The state machine explodes combinatorially during compilation, before the input is meaningfully read.
This means the input size is irrelevant — the same pattern would hang on a 17-byte file. Feeding it less data does not help.
Observed impact
| | During | After kill |
|---|---|---|
| Free RAM | 179 MiB | 3.7 GiB |
| Swap used | 23 GiB | 12 GiB |
| zram | 15.3 / 15.3 G (100%) | 11.1 / 15.3 G |
| Disk swapfile | 9 GiB | 1.8 GiB |
| Memory pressure (full) | 21.3% | 0.00% |
| Load average | 13.26 | 0.64 |
Growth was roughly linear at ~200 MB/min and showed no sign of converging.
Aggravating factor: OOM protection is inherited
This host is configured (deliberately) so the OOM killer will not select Claude Code's cgroup. The ugrep child inherits that protection. The kernel therefore would not reap the runaway — it would have consumed the remaining swap and taken down something else first. Any user who has followed advice to protect Claude Code from the OOM killer converts this bug from "one slow command" into "unrecoverable host".
Suggested fixes
- Cap the search subprocess.
RLIMIT_ASin the low hundreds of MB is far above any legitimate ugrep working set. Kill and return an error to the model instead of letting it grow without bound. - Wall-clock timeout. A grep over a repo that hasn't returned in ~30 s is not going to.
- Reject pathological patterns before spawning. Nested or repeated bounded quantifiers over large character classes (
{0,N}with N above ~10, appearing more than once) are cheap to detect statically and are almost never what the model actually wants. Rewriting to[a-z0-9/_.-]*would have been instant and semantically near-identical here. - Don't inherit OOM protection into short-lived tool subprocesses — the protection is meant for the agent session, not for a disposable grep.
Secondary bug: background processes are orphaned, never reaped
Same host, same investigation. Six servers spawned by Claude Code sessions were still running 5 hours after their sessions had exited. All had been reparented to systemd (ppid 2923):
| PID | Process | Port |
|---|---|---|
| 3940610 | headless Chrome + 6 children (~130 MB) | 9366 |
| 3913158 | node -e inline static server | 4600 |
| 3928355 | node srv2.js | 8836 |
| 3907016 | python3 -m http.server | 0.0.0.0:8909 |
| 3923395 | python3 -m http.server | 8787 |
| 3944049 | python3 -m http.server | 8099 |
Note 3907016 bound to 0.0.0.0, leaving a directory served on the LAN and over Tailscale for 5 hours after the session that created it was gone. Sessions should track and tear down processes they spawn, or place them in a cgroup that dies with the session.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Independent confirmation of exactly this mechanism, with kernel-grade evidence — and possibly the fastest growth datapoint reported so far: ~27 GiB (22.1 GiB anon RSS + ~5.1 GiB swap) in ≤104 s, ≈270 MB/s.
Environment: Claude Code 2.1.232, native install (
~/.local/share/claude/versions/2.1.232, launched via the~/.local/bin/claudesymlink), Ubuntu 24.04 (kernel 7.0.0-28-generic), x86_64, 30 GiB RAM + 8 GiB swap.Trigger — model-generated, inside a research subagent's Bash tool call (timestamp T+0, from the session transcript):
Two bounded repeats around a 10-way alternation, with
-i— the exact pathological shape this issue describes. (Two seconds later a sibling command rangrep -oiE 'abstract.{0,1800}', also in the class.) The input was one curl'd arXiv abstract page — tiny, consistent with the explosion being compile-time and input-independent.Kernel evidence (
journalctl -k), 104 s after that command launched:Supporting details from the same kernel task dump:
active_fileto 0 — one process exhausted the entire 30 GiB + 8 GiB machine.headprocess (pid 2189560, matching the| head -c 4000) is PID-adjacent to the victim — consistent with the victim being the grep stage of that pipeline, i.e. the CC binary re-exec'd as ugrep.commshows the executed binary's basename —claudehere via the symlink; the2.1.224-style comms in #84960 are the same thing via the versions dir.Cross-refs: root-cause analysis thread in #84960; #4953 / #86202 look like probable same-cause reports.
One consequence worth recording for Linux/tmux users: where tmux ≥ 3.4 places each pane in its own systemd user scope, the default
OOMPolicy=stopescalates this kill into a full pane teardown — systemd stops the scope, interactive bash ignores SIGTERM, and 90 s later everything left in the pane (including a freshly restarted claude) gets SIGKILLed. Until ugrep gets a memory limit/timeout, effective local containment is a user-level drop-in ontmux-spawn-.scope.d/withOOMPolicy=continue+MemoryMax=— a capped re-exec then dies alone inside its pane within seconds instead of taking the machine down.Confirming this on 2.1.233 (one version newer than the report above), and specifically confirming the systemd scope consequence @DanTremonti describes at the end of that comment. In his case it was a predicted escalation. Here it happened twice in fourteen minutes, and both times it destroyed a live session rather than just thrashing the host.
Environment: Claude Code 2.1.232 then 2.1.233 (native,
~/.local/share/claude/versions/, launched via the~/.local/bin/claudesymlink), Ubuntu, kernel 7.0.0-28-generic, x86_64, 31 GB RAM + 31 GB swap, tmux panes in per-pane systemd user scopes.Two kills, same shape, different CLI versions:
Note
total-vmdiffers by 1668 kB across two independent runs on two different binaries, which matches the "deterministic, allocated at compile time" finding in #82230.The session's own process was a separate, healthy 232 MB in the same OOM table. The victim is the re-exec'd child, and
commis the versioned binary's basename becauseexec -a ugrepsets argv[0] whilecommcomes from the executed file.The consequence, which is the part I want to add. Both victims were inside a
tmux-spawn-*.scope. systemd failed the whole scope onoom-killand took every process in it down, including the session's own healthy CLI. From the user's side there is no signal that a search did this. A long-running session simply vanishes mid-task, twice, and the natural conclusion is that the assistant crashed or leaked. It took kernel logs to find out the session was collateral damage from its owngrep. Both dead transcripts end with the offending call and the tool result recorded only at the moment of death,Exit code 137andExit code 144, so from inside the session the Bash call simply hung for four minutes and then the session was gone. Measured gap from tool call to kill: 4m22.1s and 4m06.4s.One thing I have not seen stated anywhere in these threads, which usefully bounds triage: the shim functions are not exported. Measured three ways on bash/Linux:
So shell scripts, git hooks and cron jobs are unaffected even when invoked from the Bash tool. Only a
greptyped directly into the Bash tool's own snapshot shell reaches ugrep. Worth knowing before anyone audits their scripts for this.Sanitised repro (neutral tokens, synthetic input, same shape as the real one that killed us):
Two bounded repeats around an alternation with
-oand-i, as in the original report.On mitigation.
--allowedTools Grep Globon the launch line does switch the shim off, verified both directions (type -t grepbecomesfileand the engine becomes GNU grep 3.11), and it does not restrict the tool roster underbypassPermissions. But that only helps a session at spawn time, so every already-running session stays exposed until it restarts. @DanTremonti'stmux-spawn-.scope.d/drop-in withOOMPolicy=continueplusMemoryMax=is the better containment for anyone in this position, because it protects sessions that are already up. Cross-ref #65211 for the--allowedToolsopt-out being CLI-args-only, and #69736 for the standing opt-out request.The engine bug behind this thread is already fixed upstream — the actionable fix
is a version bump, not new guardrails.
The embedded ugrep is 7.5.0 (
ARGV0=ugrep "$CLAUDE_CODE_EXECPATH" --version),which sits inside the affected range of a known upstream bug:
2026-07-18): *POSIX ERE with two bounded wildcard intervals exhausts memory
during pattern compilation (5.0.0–7.8.2)* — exactly the
.{0,N}…{0,M}shapereported across this thread and #86942/#87129;
compilation allocates 14+ GB and runs minutes before "exceeds complexity
limits"*;
(fail-fast on DFA complexity), shipped in ugrep v7.8.4 on 2026-08-05.
This also explains the observation in #86942 that 7.5.0 "sometimes bails with
exceeds complexity limits— but only after burning the memory": the complexitycheck exists in 7.5.0 but runs late;
Genivia/ugrep#556 moves it to
fail fast. So bumping the
vendored ugrep to ≥ 7.8.4 turns every incident in this thread into an immediate,
readable one-line error.
One more failure mode for the spectrum — swapless host, no OOM kill at all.
The reports above end in OOM kills or swap thrash. On a 16 GB Linux host with no
swap (claude-code 2.1.228, npm install), an agent-written
grep -noiE '.{0,120}(word1|word2).{0,160}' notes.mdon an ordinary 337 KBMarkdown file allocated at ~120 MiB/s, ate 11 GiB in ~2 minutes, and the machine
entered a reclaim livelock: desktop frozen, one keyboard interrupt processed per
minute (atkbd journal),
systemd-journald: Under memory pressure×26, and zerokernel OOM kills — file-page reclaim kept "making progress" by evicting hot
executable pages. Hard reset was the only exit. Worth noting for triage: on
swapless machines this bug presents as a total freeze with an empty OOM log,
which is what sent our investigation down three wrong paths before landing here.
Independent confirmations of the isolations already in the thread, measured on the
real incident file plus a deterministic generator: the trigger is the product of
the two bounds (one wide interval alone: instant; both: multi-GiB), ASCII-only
detonates identically,
-inot required. Deterministic pure-ASCII repro, noprivate data (detonates at ~3.9 GiB within 30 s under a 4 GiB cap):
Mitigation we validated live while waiting for the bump, complementing the caps
suggested in #86942: a container/cgroup memory limit (
docker update) converts the failure from host death into a contained kill of the--memory=10g
grep child — the same pattern that froze the host now just increments the cgroup's
oom_killcounter. And per-call,command grepbypasses the shadow (\grepdoesnot — it escapes aliases, not functions).