Bundled ugrep runs with no memory limit or timeout — model-generated regex consumed 13.6 GB and thrashed the host

Status Open
Reported on v2.1.228
Maintainer reply None cached
Activity 5 comments · opened Aug 13, 2026

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

  1. Cap the search subprocess. RLIMIT_AS in 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.
  2. Wall-clock timeout. A grep over a repo that hasn't returned in ~30 s is not going to.
  3. 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.
  4. 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.

View original on GitHub ↗

3 Comments

DanTremonti · 16 days ago

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/claude symlink), 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):

curl -sL --max-time 90 "https://arxiv.org/abs/2509.05835" | sed -e 's/<[^>]*>//g' | tr -s ' \t\n' ' ' | \
  grep -oiE '.{0,40}(abstract|attack success|100%|AudioSeal|WavMark|Timbre|white-box|black-box|gray-box|overwrit).{0,120}' | head -c 4000

Two bounded repeats around a 10-way alternation, with -i — the exact pathological shape this issue describes. (Two seconds later a sibling command ran grep -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:

oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=avahi-daemon.service,mems_allowed=0,global_oom,task_memcg=/user.slice/user-1001.slice/user@1001.service/app.slice/app-org.gnome.Terminal.slice/tmux-spawn-<uuid>.scope,task=claude,pid=2189561,uid=1001
Out of memory: Killed process 2189561 (claude) total-vm:29693180kB, anon-rss:22130952kB, file-rss:1752kB, shmem-rss:0kB, UID:1001 pgtables:54176kB oom_score_adj:200

Supporting details from the same kernel task dump:

  • The victim additionally held 1,345,104 swapents (~5.1 GiB); host-wide free swap was down to 192 kB and active_file to 0 — one process exhausted the entire 30 GiB + 8 GiB machine.
  • A head process (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. comm shows the executed binary's basename — claude here via the symlink; the 2.1.224-style comms in #84960 are the same thing via the versions dir.
  • The interactive session's own claude process was separately visible at a normal 251 MB — the session wasn't the consumer, matching the "it's the grep shim" finding in #84960's comments.

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=stop escalates 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 on tmux-spawn-.scope.d/ with OOMPolicy=continue + MemoryMax= — a capped re-exec then dies alone inside its pane within seconds instead of taking the machine down.

Sn3th · 15 days ago

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.

Authorship: investigated and written by the agent in the session, posted from the account holder's GitHub. Adding it here rather than opening a duplicate.

Environment: Claude Code 2.1.232 then 2.1.233 (native, ~/.local/share/claude/versions/, launched via the ~/.local/bin/claude symlink), 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:

12:17:16  oom-kill ... task_memcg=/user.slice/.../tmux-spawn-<uuid-a>.scope
          Killed process 4121039 (2.1.232) anon-rss:24703240kB  total-vm:46472440kB
12:31:48  oom-kill ... task_memcg=/user.slice/.../tmux-spawn-<uuid-b>.scope
          Killed process 164617  (2.1.233) anon-rss:25856968kB  total-vm:46474108kB

Note total-vm differs 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 comm is the versioned binary's basename because exec -a ugrep sets argv[0] while comm comes 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 on oom-kill and 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 own grep. Both dead transcripts end with the offending call and the tool result recorded only at the moment of death, Exit code 137 and Exit 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:

bash script.sh        -> type -t grep = file, grep (GNU grep) 3.11
./script.sh (shebang) -> type -t grep = file, grep (GNU grep) 3.11
bash -c '...'         -> type -t grep = file

So shell scripts, git hooks and cron jobs are unaffected even when invoked from the Bash tool. Only a grep typed 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):

python3 -c "
import random; random.seed(7)
w=['alpha','bravo','charlie','delta','echo','foxtrot','golf','hotel','india','juliet','kilo','lima','mike']
print('\n'.join(' '.join(random.choice(w+['filler','padding','token','value']) for _ in range(random.randint(30,300))) for _ in range(1471)))
" > /tmp/synth.txt
# 1.5 MB, 1471 lines, longest line 1863 chars

P='.{0,60}(alpha|bravo|charlie|\bdelta\b|\becho\b|foxtrot|golf|hotel|india|juliet|kilo|lima|mike).{0,60}'

( ulimit -v 2000000; command grep -n -oiE "$P" /tmp/synth.txt >/dev/null ); echo "gnu  exit=$?"
( ulimit -v 2000000; grep         -n -oiE "$P" /tmp/synth.txt >/dev/null ); echo "shim exit=$?"
gnu  exit=0    0.016 s
shim exit=139  7.31 s     (SIGSEGV at the 2 GB cap; uncapped this is the 25 GB climb above)

Two bounded repeats around an alternation with -o and -i, as in the original report.

On mitigation. --allowedTools Grep Glob on the launch line does switch the shim off, verified both directions (type -t grep becomes file and the engine becomes GNU grep 3.11), and it does not restrict the tool roster under bypassPermissions. But that only helps a session at spawn time, so every already-running session stays exposed until it restarts. @DanTremonti's tmux-spawn-.scope.d/ drop-in with OOMPolicy=continue plus MemoryMax= is the better containment for anyone in this position, because it protects sessions that are already up. Cross-ref #65211 for the --allowedTools opt-out being CLI-args-only, and #69736 for the standing opt-out request.

ksblazh · 13 days ago

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} shape
reported 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 complexity
check 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.md on an ordinary 337 KB
Markdown 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 zero
kernel 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, -i not required. Deterministic pure-ASCII repro, no
private data (detonates at ~3.9 GiB within 30 s under a 4 GiB cap):

python3 - <<'PY'
import random
random.seed(7)
words = ("policy backlog priority category filing chart bulletin movement "
         "retrogression review docket notice comment period agency final rule "
         "proposed ").split()
out = []
for i in range(1500):
    line = " ".join(random.choice(words) for _ in range(random.randint(400, 1200)))
    if i % 17 == 0: line += " unavailable "
    if i % 23 == 0: line += " unreachable "
    out.append(line)
open("synth.md", "w").write("\n".join(out) + "\n")
PY
# inside a Claude Code Bash-tool session (the shadow function is what's under test;
# a `bash script.sh` wrapper bypasses it and silently benchmarks system grep):
grep -noiE '.{0,120}(unavailable|unreachable).{0,160}' synth.md > /dev/null

Mitigation we validated live while waiting for the bump, complementing the caps
suggested in #86942: a container/cgroup memory limit (docker update
--memory=10g
) converts the failure from host death into a contained kill of the
grep child — the same pattern that froze the host now just increments the cgroup's
oom_kill counter. And per-call, command grep bypasses the shadow (\grep does
not — it escapes aliases, not functions).

Showing cached comments. Read the full discussion on GitHub ↗