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)

Status Open
Reported on v2.1.214
Maintainer reply None cached
Activity 4 comments · opened Jul 18, 2026

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 in
dmesg 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 runs
exec -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 is
wsl --shutdown from PowerShell, which kills every running session.

Suggested fixes

Either would be enough on its own:

  1. 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.

  1. 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.

View original on GitHub ↗

3 Comments

Helban · 1 month ago

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:

.{0,150}brand.{0,150}     -> 18.3 GB RSS, still climbing when killed
.{0,120}mention.{0,120}   ->  8.6 GB RSS and rising, killed at 16.3 GB

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:

BLOWUP  .{0,150}brand.{0,150}     variable bound both sides
BLOWUP  .{0,100}brand.{0,100}
BLOWUP  .{0,60}brand.{0,60}
ok      .{0,40}brand.{0,40}       below the threshold
ok      brand.{0,150}             trailing variable bound alone
ok      .{0,150}brand             leading variable bound alone
ok      .{150}brand.{150}         fixed width both sides

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.

comm stays claude.exe for these, so dmesg and ps blame the CLI rather than the grep. Recording /proc/<pid>/cmdline at 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.

Helban · 1 month ago

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.

tryecarmack · 1 month ago

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 VmRSS from /proc/<pid>/status, sampled every 5 s.

1. Your exact shape, confirmed on 2.1.220

grep -oE '[^"]*\.exe[^"]{0,120}' <a 1,281,005 B JSONL file with very long lines>

Trailing {0,120} bound, one file, no recursion. Reached 4,029 MB RSS and was still climbing
when 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 grep calls from a single agent session killed the machine. None of
their 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:

13:50:01Z  820 MB   (first sample after the recursive grep at 13:49:58.8Z)
13:50:06Z  1,721 MB
13:50:11Z  2,556 MB          ~90 MB/s ramp
13:53:51Z–13:55:24Z  MemAvailable 78–140 MB, SwapFree 9,707 MB -> 65 MB
13:55:24Z  last sample before the box stopped scheduling
14:45:35Z  one final sample: MemAvailable 29 MB, SwapFree 0
           unresponsive until a hard reboot at ~22:57Z — 9 h 13 m of downtime

systemd-journald reported the journal corrupted or uncleanly shut down on next boot.

3. The forced -G from #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>/cmdline today:

ugrep -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg
      --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl -oE <pattern> <file>

Note -G and -oE both present. If the forced -G is what routes these patterns onto a
backtracking-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 /proc with comm = claude.exe. That is an ELF, at
lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe, hardlink count 2, sharing an inode with
node_modules/@anthropic-ai/claude-code-linux-x64/claude — i.e. it is the CLI itself, re-exec'd with
argv[0]="ugrep". On Linux the .exe name reads as a stray Windows binary, and because comm is
identical to the agent CLI's, comm cannot 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:

  1. Bound the embedded search's allocation and fail the tool call rather than the host.
  2. Give the search mode a distinguishable comm/argv[0] (e.g. claude-ugrep). claude.exe on

Linux actively misleads, and identical-to-the-agent comm makes it un-filterable.

Happy to supply the raw 5 s samples across the freeze window if useful.

Showing cached comments. Read the full discussion on GitHub ↗