Embedded ugrep in the Bash grep shim allocates ~29 GB compiling `.{0,N}(a|b|c).{0,M}`, OOM-kills the host
Summary
The grep shim installed by Claude Code's shell snapshot routes grep to the
embedded ugrep inside the claude binary. On a regex of the form.{0,N}(alt1|alt2|...).{0,M} — a bounded quantifier on both sides of an
alternation — the embedded ugrep allocates ~29 GB of RSS during regex
compilation, before reading any input, and is killed by the kernel OOM killer.
Input size is irrelevant: a 28 KB file triggers it. The allocation is
deterministic — total-vm matched to the kilobyte across three separate kills.
On a shared 31 GB host this took the whole machine down: everything else was
pushed to swap and sshd stopped completing handshakes for ~40 minutes. It
happened three times before we identified the cause, because the failure looks
like "a slow regex" from inside the session, not like a memory bug.
Environment
- Claude Code
2.1.220, native binary - Linux
6.8.0-136-generic, 6 cores, 31 GB RAM, 8 GB swap - System
grep (GNU grep) 3.11— unaffected, the problem is only inside
Claude Code Bash calls
Reproduction
One line, on any text file of a few KB:
# inside a Claude Code Bash tool call (so the shim is active)
grep -oiE ".{0,45}(RCS|SAS|SIRET|Paris|75[0-9]{3}).{0,35}" somefile.txt
Expected: a handful of matches.
Actual: RSS climbs to ~29 GB in seconds, then OOM-kill.
The file we hit it on was 28 445 bytes, 264 lines, longest line 4 892 chars.
Safe comparison on the exact same file and pattern:
/usr/bin/grep -oiE '<pattern>' → 13 matches in 0.104 s
embedded ugrep, ulimit -v 2 GB → SIGSEGV, 0 lines
embedded ugrep, no limit → 29 GB RSS → OOM-kill
Bisect
Each run under ulimit -v 2000000 so it segfaults instead of taking the host
down:
| pattern | flags | result |
|---|---|---|
| .{0,45}(RCS\|SAS\|SIRET\|Paris\|75[0-9]{3}).{0,35} | -oiE | SEGV |
| same, without -i | -oE | SEGV |
| same, without -o | -iE | SEGV |
| without the trailing .{0,35} | -oiE | ok, 18 lines |
| without the leading .{0,45} | -oiE | ok, 13 lines |
| bare alternation (RCS\|SAS\|SIRET\|Paris\|75[0-9]{3}) | -oiE | ok, 20 lines |
| .{0,45}(SIRET).{0,35} — one branch, both bounds | -oiE | ok |
So the trigger is specifically bounded quantifier on both sides × alternation
with more than one branch. -i and -o are not involved.
Kernel evidence
Jul 29 07:40:18 Killed process 2527415 anon-rss: 29 139 584 kB total-vm: 33 841 168 kB
Jul 29 08:24:31 Killed process 2539464 anon-rss: 28 779 264 kB total-vm: 33 841 232 kB
Jul 29 09:21:23 Killed process 2556134 anon-rss: 28 858 240 kB total-vm: 33 841 232 kB
Identical total-vm across three runs on different files supports the reading
that this is compile-time state expansion, not data-dependent matching.
Host impact each time: load average to 304 on 6 cores, swap exhausted
(7.6 of 8.0 GB), PSI memory full 65 % and io full 53 % over 5 minutes,
co-tenant services (postgres, n8n, others) evicted to swap, sshd accepting TCP
on :22 but dropping the session before the handshake completed.
The shim
~/.claude/shell-snapshots/snapshot-bash-*.sh:
# Shadow find/grep with embedded bfs/ugrep
unalias grep 2>/dev/null || true
function grep {
...
(exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git ... "$@")
}
Why this is worth fixing rather than documenting
- The pattern shape is completely ordinary — "show me N characters of context
around any of these words" is a normal thing to write, and it is what a model
naturally writes when asked to check whether a fact is present in a file.
- The failure is silent and misleading from inside the session: the Bash call
just exceeds its timeout and gets backgrounded. Nothing indicates memory.
That is why we re-ran it twice more.
grepis shadowed transparently, so neither the user nor the model has any
signal that a different engine with different characteristics is in play.
- Blast radius is the whole host, not the session. On shared or CI machines one
agent can evict everything else.
Suggested mitigations, in order of usefulness
- Cap the embedded ugrep's regex-compilation memory and fail with an error
instead of allocating without bound.
- Impose an address-space limit (
RLIMIT_AS) on the shim's subprocess, so the
worst case is one dead process rather than a dead machine.
- Fall back to system
grepwhen compilation exceeds a budget. - At minimum: make the shim visible, so
grep --versioninside a Claude Code
Bash call does not look like GNU grep.
3 Comments
Reproduced on 2.1.228, four times out of four, with per-process evidence that
pins the allocator. Adding it here rather than opening a duplicate.
Reproducer
A bare, top-level Bash-tool call against a 69 KB / 851-line JSON file:
Same shape as the original report: a bounded quantifier on both sides of an
alternation, with
-o. Output is zero bytes (the pattern does not match the file),which is consistent with the allocation happening at compile time as described.
The allocator, named
Sampling every process by RSS every 2 s from a separate systemd scope (so the
sampler outlives the victim):
The session process holds 0.25 GB for the entire event. The per-command
claude.exehelper climbs at ~250 MB/s for ~90 s to ~17 GB. Kernel reports across incidents:
Why this is easy to misdiagnose (cost me several hours)
Every attempt to contain it silently changes what runs.
grepat the top level of aBash-tool call is the shim;
grepinside any wrapper is not:So
bash -c,systemd-run --scope,ulimit -vandtimeoutall "fix" it — becausethey invoke real GNU grep, which runs the pattern instantly with flat memory. I read
those as clean controls and twice concluded the regex was innocent. Anyone trying to
bound this with
ulimit -vor a cgroup will get a false negative. Verified thecontainment itself works (a known 4 GB hog dies at exactly 2 G under
MemoryMax=2G),so the negative result is the shim being bypassed, not the cap failing.
Ruled out by experiment along the way: concurrency (a 22-agent workflow held RSS flat at
0.60 GB peak), host load, a full
/tmp, and GNU grep itself.Session-loss impact, confirming #82179
Each tmux window is its own systemd scope. The OOM kill takes the scope, so the login
shell and
claudedie with the helper: the window disappears, the in-flight call returnsexit 137, and a
--remote-controlsession is archived by the client because it vanishedwithout a clean disconnect. From the user's side that reads as three unrelated failures
— "tmux closed", "session archived", "session gone" — with nothing pointing at memory.
Transcripts survive and are resumable, but nothing in-session indicates why.
Suggestions
instead of allocating without bound. A failed
grepis recoverable; a dead session isnot.
on both sides of an alternation) rather than compiling it.
OOMPolicy=continuefor the scope, or running the helper in its own cgroupwith a ceiling, so a runaway helper cannot take the session with it.
Fixed in 2.1.235 — changelog: "Improved the embedded
grepin native macOS/Linux builds: pathological patterns now fail fast instead of exhausting memory, and-m Nwith-A/-Cprints correct context" (https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md). Closing — reply to reopen if it still happens on the latest version.🤖 Generated with Claude Code