grep shell shim (claude-as-ugrep): unbounded memory growth on ERE bounded quantifiers {m,n} — OOMs the host

Status Open
Reported on v2.1.201
Maintainer reply ✓ Yes — bcherny
Activity 6 comments · opened Jul 4, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Summary

The shell-integration grep shim (the grep() shell function that re-execs the claude binary with ARGV0=ugrep) has unbounded memory growth when the ERE pattern contains bounded quantifiers like {0,60}. It produces no output, never completes, and grows RSS at ~100–120 MB/s until the host OOMs.

This took down a 16 GB production server twice in one morning: three shim-grep processes reached 14 GB+ RSS each, exhausted swap, and drove load average past 12 (widespread D-state, box unresponsive). Worse, the agent session that issued the grep retries it after the process is killed, so the box re-freezes minutes later — the failure loops.

GNU grep runs the identical pattern on the identical file in 1–2 ms. The same claude binary invoked as rg (ripgrep emulation) also handles the pattern instantly — only the ugrep/grep emulation path is affected.

Environment

  • Claude Code 2.1.201 (/home/<user>/.local/bin/claude, Bun v1.4.0 (63bb0ca0d) Linux x64 baseline)
  • Linux kernel 6.17, glibc 2.36, zsh
  • Triggered organically by a Claude session running grep -oiE '.{0,50}receipt.{0,60}' file.html-style commands via the Bash tool (the shell snapshot's grep() function routes these to the built-in engine)

Minimal reproduction

# 1. Any ordinary text file works — 90 KB, 1000 short lines:
seq 1 1000 | sed 's/$/: roadside service call quote with $95 price and filler words to pad the line/' > /tmp/trivial.txt

# 2. Run the claude binary in its ugrep persona (what the grep() shim does):
( exec -a ugrep ~/.local/bin/claude -oE '[^{};]{0,60}\$95[^{};]{0,70}' /tmp/trivial.txt )
# → no output, RSS grows ~120 MB/s indefinitely; kill it before it OOMs your machine

# 3. Control — GNU grep, same pattern, same file:
command grep -coE '[^{};]{0,60}\$95[^{};]{0,70}' /tmp/trivial.txt
# → "9", in ~1 ms

Measured RSS growth of step 2 (identical curve on a real-world 80 KB HTML file):

t=2s  RSS=301MB
t=4s  RSS=543MB
t=6s  RSS=773MB
...linear, reached 14.8 GB in ~2.5 min on the production incident before being killed

Isolation matrix

| Invocation | Pattern | Result |
|---|---|---|
| exec -a ugrep claude -G -oE | [^{};]{0,60}\$95[^{};]{0,70} | ❌ hangs, unbounded RSS |
| exec -a ugrep claude -oE (no -G) | same | ❌ hangs |
| exec -a ugrep claude -G -E (no -o) | same | ❌ hangs |
| exec -a ugrep claude -E (no -G/-o) | same | ❌ hangs |
| exec -a ugrep claude -G -oE | \$95 (no quantifier) | ✅ instant, correct 9 matches |
| exec -a rg claude -o | [^{};]{0,60}\$95[^{};]{0,70} | ✅ instant, correct 9 matches |
| command grep -oE (GNU) | same | ✅ 1 ms, correct 9 matches |

So: the trigger is bounded quantifiers ({m,n}) in the ugrep emulation's ERE engine, independent of -o/-G. The rg emulation in the same binary is unaffected.

Expected behavior

Match (or fail) in milliseconds like GNU grep and the binary's own rg mode — or at minimum, bail out / cap memory rather than consuming all host RAM with no output.

Impact note

Because the shim transparently intercepts plain grep inside Bash tool calls, models naturally generate grep -oE '.{0,50}<term>.{0,60}'-style context-window searches and have no way to know they are invoking the buggy engine. A single such call can freeze the entire host, and the session then retries it after a kill. Workaround we're using: instruct sessions to use command grep / the Grep tool.

View original on GitHub ↗

3 Comments

ledurnan · 1 month ago

Seeing the same thing on 2.1.201, kernel 6.17, 32GB Linux box.

Came up during normal use. A session ran a bog-standard scrape through the Bash tool (curl a docs page, strip nulls, pull text around some keywords):

curl -sL "https://example.com/some/docs/page" \
| tr -d '\0' \
| grep -oiE '[^<>]{0,200}(alpha|bravo|charlie|delta|echo|foxtrot)[^<>]{0,200}' \
| head -40

The grep() shim turned that into the claude binary re-exec'd as ugrep:

exec -a ugrep ~/.local/share/claude/versions/2.1.201 \
-G --ignore-files --hidden -I --exclude-dir=.git ... \
-oiE '[^<>]{0,200}(alpha|bravo|...)[^<>]{0,200}'

What I saw:

  • RSS shot up to 18.9 GB (VmRSS 18960380 kB, VmSize ~21 GB, VmSwap 0). No output, 99.9% CPU, ran 2m41s before I killed it. Growing ~100-120 MB/s, same as you're describing.
  • 31 GB was enough headroom that the kernel OOM-killer never fired (nothing in the journal), so I caught it by hand. It ignored SIGTERM, had to SIGKILL it. Once it died the whole ~18 GB came straight back (host went from 27 to 7.3 GiB used).
  • While it was alive it pushed everything else into swap (swap 7.7/8.0 GB, free RAM down to 431 MB). Chrome fell over as collateral, not an OOM kill, its GPU child just couldn't fork under the pressure (GPU process launch failed: error_code=1002, then GPU process isn't usable. Goodbye.).

Couple of things that might help narrow it down:

  • My pattern uses a negated class [^<>]{0,200} twice, not .{0,N}. So whatever #67021 is seeing with two bounded intervals isn't specific to .; a bounded [^...]{0,N} pair does it too.
  • Re the retry loop you mention: I had several other sessions running with the same shim, so anything issuing a similar pipeline would've hit it again.
rrrrrrrrrricoxi · 1 month ago

Cross-linking from #77230 (closed as a duplicate of this) — I ended up isolating an even more minimal repro that pins down the exact trigger, might be useful for whoever's fixing this.

Minimal case: single ~120-byte line, no file needed, reproducible with the standalone published ugrep 7.8.2 binary (independent of Claude Code's bundling):

printf 'Sample analysis indicates a positive trend across replicates for batch 1, ratio test X70 / Y30 concentration noted.' > mini.txt

# Hangs indefinitely (killed after 5s via timeout; CPU pinned ~95-100% the whole time):
timeout 5 ugrep -G -oE '.{0,40}X ?70.{0,40}' mini.txt   # exit 124, never produced output

# Near-instant with a slightly smaller bound, same input, same pattern shape:
timeout 5 ugrep -G -oE '.{0,20}X ?70.{0,20}' mini.txt   # completes in ~0.05s

Things I ruled out (hang persists regardless):

  • Alternation — a single literal branch (no (...|...)) reproduces it just as reliably.
  • -o — dropping it (plain -E instead of -oE) still hangs.
  • -G vs -E (BRE vs ERE) — both hang identically.
  • CJK / multi-byte text — reproduces on pure ASCII input just the same.

What it does depend on: a bounded-repetition wildcard on both sides of the match simultaneously, with the bound around ~40. Same one-line input: .{0,20}...{0,20} completes in ~50ms, .{0,40}...{0,40} never completes (still running after 5+ seconds in every trial; in the original in-the-wild case that prompted #77230 it ran 20+ minutes before being manually killed). Didn't bisect the exact transition point, but 20 vs 40 alone is already a >100x slowdown — far more than the ~2-4x you'd expect from doubling scanned context, consistent with an exhaustive both-sided search (e.g. for POSIX leftmost-longest matching) rather than an early-exit/anchored one.

Since this reproduces with the standalone published ugrep binary, it may be worth relaying to https://github.com/Genivia/ugrep upstream too, if not already tracked there.

Separately (may be worth a distinct issue if not already covered): when the wrapping background shell running the stuck search was sent SIGTERM, the shell exited but its ugrep child was not terminated with it — it was reparented to PID 1 and kept consuming ~90-100% CPU until manually SIGKILL'd. So killing the wrapping shell/task doesn't actually free the CPU in this case.

bcherny collaborator · 14 days ago

Confirmed — reproduced on 2.1.233 (Linux and macOS): a pattern with a two-digit bounded repeat like .{0,50}receipt.{0,60} run through the shell's grep in a Claude Code session spins with rapidly growing memory and never finishes, while system grep and rg handle it instantly.

The cause is on our side (the grep compatibility shim Claude Code installs into the tool shell), and a fix is landing in an upcoming release: those patterns are routed to the system grep instead. Until you're on a build with the fix, command grep … or rg sidesteps the shim. Thanks for the very precise report — it made this quick to pin down.

🤖 Generated with Claude Code

Showing cached comments. Read the full discussion on GitHub ↗