Bundled ugrep allocates 4-17 GB to search a 64 KB file when the pattern has a trailing `.{N}` bound (Linux/WSL2, 2.1.214)
Summary
The Grep tool runs the ugrep engine bundled inside bin/claude.exe. On a
regular expression that combines a trailing bounded repetition .{N} with a
variable inner bound .{0,M}, that engine allocates memory at a steady
~230 MB/s for tens of seconds before answering.
Two measured cases, both on inputs of about 64 KB:
| pattern | peak RSS | wall time |
|---|---|---|
| (alpha .{0,40}beta\|gamma).{50} (minimal) | 4.35 GB | 25 s |
| the production pattern below | 17.2 GB (sampled) | not measured to completion |
GNU grep answers the same queries in about 4 milliseconds using a few MB.
This is not an unbounded leak. The allocation is finite and proportional to the
compiled pattern, and the process does terminate once it gets the memory it
wants. The problem is the size of that appetite: on a machine that cannot supply
it, the process thrashes against the limit or the kernel OOM killer fires. A
64 KB haystack containing no match at all should not cost gigabytes.
The patterns come from the assistant, not from the user, so there is no habit a
user can change to avoid it.
Environment
- Claude Code 2.1.214, npm global install
(~/.npm-global/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe)
- Ubuntu on WSL2, kernel 6.18.33.2-microsoft-standard-WSL2
- WSL VM capped at 48 GB, 8 GB swap
What was observed
An RSS watchdog caught the process while it was still alive and recorded:
{"pid": 28565, "comm": "claude.exe", "rss_gib": 17.23,
"cmdline": "ugrep -G --ignore-files --hidden -I --exclude-dir=.git [...] -o -i -E .{200}(listow[a-z]* .{0,40}wymiar|wymiar.{0,60}listow|[...]).{200} /tmp/[...]/[redacted].txt",
"parent_chain": "bash(28563) <- bash(28531) <- claude(6474) <- [...]"}
The input file was 69,799 bytes. This particular process was not OOM-killed:
by then the VM had 47 GB free, and it exited on its own.
Two earlier incidents on the same machine are what prompted the watchdog. Both
were claude.exe processes of the same magnitude, both exhausted a 20 GB VM
along with 8 GB of swap, and both were killed by the kernel:
Jul 16 23:01 Killed process 586284 (claude.exe) anon-rss:16675392kB # CLI 2.1.211
Jul 18 14:12 Killed process 12378 (claude.exe) anon-rss:16054656kB # CLI 2.1.214
Their cmdlines were not captured, so tying them to this same Grep pattern is
inference from the matching process name and size, not proof. The 17.2 GB
measurement above stands on its own regardless.
Note the process name: comm is claude.exe because the binary is multi-call
and dispatches on argv[0]. Anyone looking at a runaway "claude" process indmesg will see the CLI's own name and reasonably conclude the CLI is leaking.
That is what happened here, and it sent the investigation in the wrong direction
for two days. Recording the cmdline is what turned it around.
Growth curve
Minimal pattern, 64 KB file, under a 10 GB cgroup cap
(systemd-run --user --scope -p MemoryMax=10G), sampling RSS once per second:
t(s) RSS(MiB)
1 324
5 1357
10 2520
15 3407
20 4117
23 4350 <- peak
25 process exits, no match found
Linear at ~230 MB/s until the compiled pattern is satisfied, then it answers.
Reproduced twice with peaks of 4330 and 4350 MB.
The cap size decides what failure looks like. Under a 3 GB cap the same run
never completed in 20 seconds: it sat pinned at the cap, thrashing, because the
memory it wanted was not available. That is the shape a user sees on a machine
with less RAM than the pattern demands.
Minimal reproduction
Self-contained, no private data. Generate 64 KB of filler with no match in it:
python3 -c "
import pathlib
line = 'the quick brown fox jumps over the lazy dog 0123456789 '
pathlib.Path('synth.txt').write_text((line * 20 + chr(10)) * 60)
"
CLAUDE_BIN=~/.npm-global/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe
# Peaks at ~4.3 GB and takes ~25s. Keep the cap: on a smaller machine this is
# the difference between a slow grep and a dead login session.
systemd-run --user --scope -p MemoryMax=10G \
bash -c "exec -a ugrep $CLAUDE_BIN -i -E '(alpha .{0,40}beta|gamma).{50}' synth.txt"
The recorded production argv reproduces it too, including the -G and the--exclude-dir flags that the Bash tool's grep shim prepends. In a Bash tool
session grep is a shell function that runsexec -a ugrep "$CLAUDE_CODE_EXECPATH" -G --ignore-files --hidden -I --exclude-dir=... "$@",
so an ordinary grep call from the model lands on this engine. That is how it
was hit in practice, twice.
One caveat for anyone verifying this: timeout 15 grep ... does not exercise
the shim, because timeout execs a binary and shell functions are invisible to
it. That silently falls back to system grep and looks like the bug does not
reproduce. Use exec -a ugrep against the binary directly, as above.
Which part triggers it
All cases below use the same 64 KB file, under a 4 GB cap. ok means it
finished in under a second; BLOWUP means it was still allocating when killed
at 12 seconds, having already taken gigabytes.
ok (alpha .{0,40}beta|gamma) inner bound alone
ok .{200}(alpha .{0,40}beta|gamma) leading pad only
BLOWUP (alpha .{0,40}beta|gamma).{50} trailing pad only
BLOWUP .{50}(alpha .{0,40}beta|gamma).{50}
ok .{25}(alpha .{0,40}beta|gamma).{25} outer bound too small
ok .{200}(alpha .{0,30}beta|gamma).{200} inner bound too small
ok .{200}(alpha .{0,20}beta|gamma).{200}
BLOWUP .{200}(alpha .{0,40}beta).{200} alternation not required
ok .{200}(alpha|beta|gamma).{200} inner bound required
So it needs a trailing .{N} with N of roughly 50 or more, and a
variable inner bound .{0,M} with M of roughly 40 or more. A leading bound on
its own never triggered it in any test. An alternation is not required.
-o is not required either: (alpha .{0,40}beta|gamma).{50} with only -i -E
allocates just the same.
Comparison with GNU grep
GNU grep 3.12 (Debian package grep, invoked as /usr/bin/grep to bypass the
shim) handles both patterns on the same files instantly, exit 1, no match:
/usr/bin/grep -o -i -E '(alpha .{0,40}beta|gamma).{50}' synth.txt
/usr/bin/grep -o -i -E '.{200}(listow[a-z]* .{0,40}wymiar|[...]).{200}' [redacted].txt
Same patterns, same files, same semantics. The difference is the engine.
Impact
When this fires on a machine where the VM is smaller than the allocation, the
kernel OOM killer runs, user@1000.service dies with Result: resources, and
every new terminal tab exits instantly. On WSL the only way back iswsl --shutdown from PowerShell, which kills every running session.
Suggested fixes
Either would be enough on its own:
- Bound the engine's memory and fail the Grep call instead of allocating until
the kernel intervenes. A failed grep is recoverable; a dead user session is
not.
- Stop emitting patterns of this shape from the Grep tool. Context around a
match is what -A, -B and -C are for, and they do not carry this cost.
Related
Possibly the same root cause as #76185 (Linux, 10-15 GB anon RSS, same OOM
signature). That report attributes the growth to idle headless sessions with
background Bash tasks. If those sessions were also running Grep, the trailing.{N} pattern shape is worth checking before treating them as separate bugs.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Correction to the trigger description in the original report. It fired twice more within an hour, on a pattern shape my characterisation said should be safe, so the "trailing
.{N}plus an inner.{0,M}" rule above is too narrow.Both new occurrences came from ordinary Grep calls in a running session, on ~50-100 KB scratch files:
Neither has a fixed-width bound, and neither has a bound nested inside a group. They are just a literal with variable-length context on each side, which is the most natural way to ask for "show me what surrounds this word".
Re-running the isolation on the same 64 KB no-match file:
So the revised reading of all the data in this issue: the engine needs two bounded regions where at least one is variable-length (
.{0,M}), and both need to be big enough. A single bound is fine no matter how large. Fixed-width bounds alone are fine, including.{200}on both sides. The variable-length bound is the necessary ingredient, and the earlier case (.{50}(alpha .{0,40}beta|gamma).{50}) fits this too: its.{0,40}is the variable one.The practical threshold is low.
.{0,60}on both sides of a five-letter word is enough to ask for gigabytes, and that is well inside what anyone would write by hand.Two smaller notes from the same window:
The process is not always reaped in time. One of these was at 18.3 GB with 17 GB left on a 47 GB machine and about 90 seconds from taking the box down. It got there in under two minutes from launch.
commstaysclaude.exefor these, sodmesgandpsblame the CLI rather than the grep. Recording/proc/<pid>/cmdlineat the time of the alert is the only reason any of this was identifiable; the two OOM kills that started this investigation are still unattributed because nothing captured their argv.This is the same engine bug as #67021, which was filed five weeks earlier and has the deeper thread, including the measured growth law and a working user-side mitigation. I have added our field data there and would rather the discussion consolidate in that issue than stay split across two. Leaving this one open only for the isolation table above (fixed
.{N}bounds are safe, variable.{0,M}bounds are not); close it as a duplicate if that is more useful.Confirming this on 2.1.220 (you have 2.1.214), and adding a case where the same binary took the
whole host down rather than just the process — plus a second trigger shape that does not involve
a
{N}bound.Environment: Claude Code 2.1.220, npm global install, WSL2 (Linux 6.6.87.2-microsoft-standard-WSL2),
9,945 MB VM, 16 GB swap. All memory figures are
VmRSSfrom/proc/<pid>/status, sampled every 5 s.1. Your exact shape, confirmed on 2.1.220
Trailing
{0,120}bound, one file, no recursion. Reached 4,029 MB RSS and was still climbingwhen I killed it. A second instance running concurrently reached 6,003 MB. Combined 8.4 GB on a
9,945 MB box. Killing both returned the machine immediately — 5,383 MB used → 1,337 MB, swap
5,898 MB → 1,591 MB.
2. Same binary, no bounded quantifier, and it froze the host for 9 h
Earlier the same day, three
grepcalls from a single agent session killed the machine. None oftheir patterns contain
{N}— I checked each one:| pattern | input | peak RSS |
|---|---|---|
|
"a\|b\|c"with-rn --include=*.py .| recursive | 8,793 MB ||
"a\|b\|c\|d"with-n| one 21,494 B file | 8,765 MB ||
"campaign_status"/"def _load_dispatch" -A 22| same 21,494 B file | 6,393 MB |Plain alternation over a 21 KB file reaching 8.7 GB is the part I'd flag hardest — it suggests the
bounded quantifier in this issue's title is one trigger among several rather than the cause.
The two largest peaks land within 28 MB of each other, which looks like a ceiling being pinned
rather than a natural high-water mark — consistent with the ~8 GB V8-heap ceiling described in
#54394, though I have not confirmed that independently.
Host trajectory:
systemd-journaldreported the journalcorrupted or uncleanly shut downon next boot.3. The forced
-Gfrom #69189 is still present in 2.1.220#69189 is closed, but the behaviour it describes is still observable. This is the shim's actual argv,
read from
/proc/<pid>/cmdlinetoday:Note
-Gand-oEboth present. If the forced-Gis what routes these patterns onto abacktracking-prone basic-regex path, that closed issue may deserve reopening.
4. Attribution note — this is very hard to diagnose from outside
The runaway appears in
/procwithcomm = claude.exe. That is an ELF, atlib/node_modules/@anthropic-ai/claude-code/bin/claude.exe, hardlink count 2, sharing an inode withnode_modules/@anthropic-ai/claude-code-linux-x64/claude— i.e. it is the CLI itself, re-exec'd withargv[0]="ugrep". On Linux the.exename reads as a stray Windows binary, and becausecommisidentical to the agent CLI's,
commcannot distinguish a runaway search from a live agent.Meanwhile the agent processes themselves stayed at 90–250 MB throughout, so any per-agent memory
accounting shows the fleet as innocent while the host dies. It took a full forensic pass to attribute
this correctly.
Two things that would help operators a lot, independent of the memory fix:
comm/argv[0](e.g.claude-ugrep).claude.exeonLinux actively misleads, and identical-to-the-agent
commmakes it un-filterable.Happy to supply the raw 5 s samples across the freeze window if useful.