Bash-tool `grep` shim (ugrep emulation): catastrophic backtracking — 6.6 GB RSS / OOM kill on a 20 KB file

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

Summary

The Bash tool replaces grep with a shell function that re-execs the claude
binary as an embedded ugrep emulation (exec -a ugrep "$_cc_bin" -G --ignore-files …).
On patterns that combine -o with bounded quantifiers around an alternation,
that emulation backtracks catastrophically: memory grows without bound until the
process is OOM-killed. Real GNU grep runs the same pattern in ~2 seconds with
flat memory.

The consequence is worse than a slow command. On my machine the OOM kill took
down the entire agent session: the runaway helper is a child of the tmux scope,
and systemd's default OOMPolicy=stop failed the whole scope, SIGTERM-ing the
healthy claude process (exit 143) and killing an hour of in-progress work.

Environment

  • Claude Code 2.1.220 (native installer, ~/.local/share/claude/versions/2.1.220)
  • Ubuntu, Linux 6.8.0-117-generic, systemd 255
  • 11 GiB RAM, 4 GiB swap (fully used at the time)
  • Session running inside a tmux pane (tmux 3.4, systemd user scope)

Reproduction

Any prose/markdown file works; mine was a 20 KB, 119-line academic paper
converted to markdown (long lines, max 1039 chars).

Inside a Claude Code Bash tool call (or any shell with the Claude shell snapshot
sourced, so grep resolves to the shim):

grep -noE ".{0,300}(bibliometric|publications|China|United States|keyword|burst|co-occurrence).{0,300}" chen2025.md | sed -n '1,25p'

Observed RSS of the shim process, sampled once per second:

| t (s) | RSS |
|---|---|
| 1 | 208 MB |
| 2 | 379 MB |
| 3 | 536 MB |
| 4 | 688 MB |
| 5 | 836 MB |
| 6 | 980 MB |
| 7 | 1114 MB |
| 8 | 1252 MB |

Linear ~150 MB/s, no convergence. Left alone for ~3 minutes it reached
6.6 GB anon RSS and was OOM-killed:

kernel: Out of memory: Killed process 1832209 (2.1.220) total-vm:7623632kB,
        anon-rss:6618624kB, file-rss:2176kB, shmem-rss:0kB, UID:1000
        pgtables:13152kB oom_score_adj:200
kernel: oom-kill:constraint=CONSTRAINT_NONE,…,task_memcg=/user.slice/user-1000.slice/
        user@1000.service/app.slice/tmux-spawn-….scope,task=2.1.220,pid=1832209
systemd[999]: tmux-spawn-….scope: A process of this unit has been killed by the OOM killer.
systemd[999]: tmux-spawn-….scope: Failed with result 'oom-kill'.

Note the killed process is named 2.1.220 — the versioned claude binary running
as the ugrep helper, not the user's shell command.

Baseline with real GNU grep on the same file and pattern:

$ time command grep -noE ".{0,300}(bibliometric|…).{0,300}" chen2025.md | wc -l
17
real  0m2.103s
user  0m2.053s

Expected

The grep shim should either match GNU grep's performance envelope on this
class of pattern, or bail out to command grep when its own matcher would blow
up (a memory ceiling on the emulation would be enough — failing the command is
far better than killing the session).

Workarounds in use

  • command grep … to bypass the shim.
  • A systemd drop-in for tmux-spawn-*.scope with OOMPolicy=continue and

MemoryMax=6G, so a runaway helper dies alone instead of taking the agent
session with it.

View original on GitHub ↗

4 Comments

jcxmt125 · 23 days ago

Second independent occurrence, on 2.1.224 (this report was 2.1.220), with a
much larger blast radius — ~43 GiB anon before the kernel OOM killer fired.

I was able to reduce it to a deterministic reproducer, and I think the diagnosis
needs adjusting: this isn't catastrophic backtracking. It reproduces on a
0-byte file. The input is never the problem — the cost is paid at pattern
compilation, before any content is examined.

Minimal reproducer

: > empty.txt
grep -E '.{0,60}(x).{0,60}' empty.txt     # via the Bash tool, so grep resolves to the shim

Peak RSS 1163 MB, and still running when I killed it at 4 seconds. Empty
file. No alternation. No -o. No -i.

GNU grep 3.12, given the original pattern and file from the crash below:
9 ms, exit 1, flat memory.

What actually triggers it

All runs under systemd-run -p MemoryMax=3G -p MemorySwapMax=0, sampling RSS at
~2 Hz over a 4-second window. "Runaway" means the process was still alive and
growing when the window expired; "finished" means it exited on its own. Under a
longer window and a 2 GB cap, the original pattern climbed steadily to the cap in
10 seconds (~200 MB/s) with no sign of levelling off.

First, content-independence — the same pattern against a 15-byte file and an
empty one costs the same:

| Input | Peak RSS | Result |
|---|---|---|
| 15-byte file | 842 MB | runaway |
| 0-byte file | 841 MB | runaway |

Then the variants, all against the 15-byte file. Both bounded quantifiers must be
present — either one alone is fine:

| Variant | Peak RSS | Result |
|---|---|---|
| [^"\\]{0,120}(…)[^"\\]{0,160} (original) | 842 MB | runaway |
| leading quantifier only | 6 MB | finished |
| trailing quantifier only | 6 MB | finished |
| no quantifiers | 6 MB | finished |
| single alternative (alpha) | 850 MB | runaway |
| . instead of negated class | 1110 MB | runaway |
| without -o | 854 MB | runaway |
| without -i | 707 MB | runaway |

So -o, -i, and the alternation are all irrelevant. The trigger is simply
two bounded quantifiers of sufficient size around a group.

And the cost tracks the product of the two bounds, not either one. These runs
are all against the 0-byte file, with a single literal in the group:

| Bounds | Product | Peak RSS | Result |
|---|---|---|---|
| {0,5} × {0,5} | 25 | 6 MB | finished |
| {0,20} × {0,20} | 400 | 6 MB | finished |
| {0,30} × {0,30} | 900 | 6 MB | finished |
| {0,40} × {0,40} | 1600 | 465 MB | finished |
| {0,60} × {0,60} | 3600 | 855 MB | runaway |
| {0,80} × {0,80} | 6400 | 859 MB | runaway |
| {0,120} × {0,120} | 14400 | 895 MB | runaway |
| {0,120} × {0,5} | 600 | 6 MB | finished |
| {0,5} × {0,160} | 800 | 6 MB | finished |
| {0,120} × {0,160} | 19200 | 894 MB | runaway |

The knee is around a product of ~1600, and anything past ~3600 grows without
bound. {0,120} × {0,5} is fine while {0,120} × {0,160} is fatal, which
rules out any single bound being the culprit. That shape — cost proportional to
the product of repetition bounds, paid eagerly and independent of input — reads
like NFA/DFA state expansion during compilation with no ceiling on the state
count.

This also explains why file size has looked so inconsistent across reports here
(20 KB in the original, 263 KB in #76056, 15 bytes in mine): file size was never
the variable.

The original crash

cd "$WORKDIR"
curl -s --max-time 30 "$URL" -o sub.html; wc -c sub.html
grep -oiE '[^"\\]{0,120}(alpha|beta|gamma|delta|epsilon|zeta)[^"\\]{0,160}' sub.html | sed 's/\\n/ /g' | sort -u | head -60

(The six alternatives were ordinary search terms; I've substituted placeholders,
keeping the count and the quantifier bounds intact. Per the table above the
alternation contents make no difference — a single literal reproduces it.)

curl followed a redirect, so sub.html was 15 bytes — Redirecting...\n. The
shim still ran for 311 seconds and reached ~43 GiB (~142 MB/s, matching the
~150 MB/s in the original report) before the kernel killed it.

kernel: Out of memory: Killed process 3217102 (2.1.224) total-vm:46446556kB,
        anon-rss:21623084kB, file-rss:1144kB, shmem-rss:0kB, UID:1000
        pgtables:89052kB oom_score_adj:0
kernel: oom-kill:constraint=CONSTRAINT_NONE,...,task_memcg=/user.slice/user-1000.slice/
        user@1000.service/tmux-spawn-<uuid>.scope,task=2.1.224,pid=3217102,uid=1000
kernel: oom_reaper: reaped process 3217102 (2.1.224), now anon-rss:0kB

The kernel task dump shows the helper sitting inside the pipeline's PID range,
carrying the version string as its process name — the signature described above:

[3217094] zsh        <- the Bash tool call
[3217100] zsh
[3217101] sed
[3217102] 2.1.224    <- 5,406,055 pages RSS, 5,912,867 swap entries
[3217103] sort
[3217104] head

No grep and no curl appear anywhere in the dump. There was also no V8
JavaScript heap out of memory abort, so the allocation was external
(Buffer/ArrayBuffer) rather than V8 old-space — which is why no heap limit
caught it on the way up.

Same session-level collateral

Because the helper was a child of the tmux scope, systemd's OOMPolicy=stop
failed the entire scope. That killed the tmux server outright (the pane was the
last window of the last session), and 10 seconds later SIGKILLed the daemon's
recovery instance mid-respawn:

systemd[1282]: tmux-spawn-....scope: Stopping timed out. Killing.
systemd[1282]: tmux-spawn-....scope: Killing process 3218513 (claude) with signal SIGKILL.
systemd[1282]: tmux-spawn-....scope: Failed with result 'oom-kill'.
systemd[1282]: tmux-spawn-....scope: Consumed 7h 6min 45.571s CPU time, 21.4G memory peak, 23.6G memory swap peak.

Agreed that the blast radius is the real problem here — the runaway command
itself was disposable, but it took down the terminal multiplexer and an
unrelated in-flight agent session with it.

Environment

  • Claude Code 2.1.224 (native installer), zsh shell snapshot
  • CachyOS (Arch), kernel 7.1.4-1-cachyos, systemd 261
  • 31 GiB RAM, zram-only swap (31.1 GB zstd), vm.swappiness=150, systemd-oomd inactive
  • Background agent session running in a tmux pane (systemd user scope)

Given that a bare .{0,60}(x).{0,60} against an empty file is enough to allocate
a gigabyte in four seconds, a compile-time ceiling on the state count would seem
to cover the whole class — failing the pattern is enormously better than killing
the session.

---

*Investigation, reproduction and measurements produced with Claude Code on the
affected machine; all runs under systemd-run -p MemoryMax=3G -p MemorySwapMax=0.*

pjy4617 · 17 days ago

Independent confirmation on 2.1.231, with a new failure shape: the scope that OOMPolicy=stop tore down was a GUI application scope, so the kill took down an entire Electron IDE and its nine agent sessions — and the IDE's session-restore then rebuilt the exact conditions to do it again.

Environment: Claude Code 2.1.231 (native), Ubuntu 24.04.4, Linux 7.0.0-28, systemd 255, 30 GiB RAM / 8 GiB swap. Sessions running as PTYs inside Orca IDE (Electron AppImage), not tmux.

Two kills, four minutes apart

01:12:17  Out of memory: Killed process 600763 (2.1.231) total-vm:21292104kB, anon-rss:20070148kB,
          oom_score_adj:100, task_memcg=/user.slice/.../app.slice/app-orca-83013.scope
01:16:16  Out of memory: Killed process 618564 (2.1.231) total-vm:23389448kB, anon-rss:21389680kB,
          oom_score_adj:100, task_memcg=/user.slice/.../app.slice/app-orca-610148.scope
01:16:18  app-orca-610148.scope: Failed with result 'oom-kill'

Both compact — anon-rss/total-vm = 94.3% and 91.4% — matching the ugrep signature rather than a session heap leak (the discriminator @ragettostudio described in #84960).

The detonating command, recovered from transcripts

An agent was polling another agent's terminal through the IDE's CLI and scraping the output:

<ide-cli> terminal read --terminal $H --json 2>&1 | tr ',' '\n' \
  | grep -oiE '"[^"]{0,200}(<term1>|<term2>|<term3>|<term4>|<term5>|<term6>)[^"]{0,60}"'

(six literal alternatives, redacted; two of them contained escaped braces \{…\}, which are not quantifiers and play no part in the blow-up.)

-o + two bounded quantifiers wrapping an alternation — the exact shape in this issue. Timeline:

| time | event |
|---|---|
| 01:10:40 | grep -oiE '"[^"]{0,200}(…6 alternatives…)[^"]{0,60}"' |
| 01:12:17 | OOM #1 — 19.1 GiB, 97 s later → ~212 MB/s |
| 01:12:39 | same grep re-issued |
| 01:14:17 | same grep re-issued |
| 01:16:16 | OOM #2 — 20.4 GiB, 119 s later → ~184 MB/s |

Consistent with the ~150 MB/s you measured.

Why this was worse than losing a pane

The runaway is a grandchild of the IDE process, so it lands in the IDE's app scope. OOMPolicy=stop then failed the whole scope and killed the IDE — nine worktree sessions at once, each mid-task. On relaunch the IDE's daemon restored all nine sessions from its checkpoint, the same agent resumed and re-issued the same grep, and it died again 2 min 28 s later. Without knowing the cause it reads as "the IDE crashes on startup", and every restart re-arms the bomb.

Worth noting the misattribution risk this creates: because exec -a rewrites argv[0], the victim's comm is the version string, so the obvious reading is "Claude Code 2.1.231 leaked 20 GB". I went down that path first and nearly pinned to an older version. The rss≈total-vm ratio is what falsifies it.

Mitigations verified here

1. Constraining a transient app scope. systemd's truncated-name drop-in lookup does apply to transient scopes, which makes this fixable without touching how the app is launched. For scopes named app-orca-<pid>.scope:

~/.config/systemd/user/app-orca-.scope.d/oom-guard.conf

[Scope]
MemoryHigh=16G
MemoryMax=20G
OOMPolicy=continue

Verified end to end — systemd-run --user --scope --unit=app-orca-999999 reports OOMPolicy=continue, DropInPaths=…/app-orca-.scope.d/oom-guard.conf, and the kernel files carry it (memory.max = 21474836480). MemoryMax keeps the blow-up cgroup-local so the desktop never thrashes, and OOMPolicy=continue overrides the default that kills the healthy app alongside it. Requires the memory controller delegated to user@.service (default on Ubuntu 24.04).

2. The PreToolUse hook from #84960 works. Installed it, confirmed it takes effect in an already-running session with no restart (live canary: grep -oE "a{0,10}(x|y)b{0,10}" → denied). 15/15 on a block/allow matrix including /usr/bin/grep and command grep bypasses, single-range patterns, small bounds like [0-9]{3,4}, and a Python heredoc containing {0,110}. One adjustment: I widened the fallback alternation to (?:e|f|z|u)?grep so the unparseable-command path also catches bare ugrep, matching the tokenized path.

Neither is a fix — both just contain the blast. The underlying request stands: the bundled ugrep needs a memory ceiling and a real timeout (#86238), and the shim should not silently shadow grep with an engine that has this failure mode.

Happy to provide the full kernel task dumps or the daemon session logs.

bcherny collaborator · 13 days ago

Confirmed / reproduced on 2.1.234 (Linux): with the in-session grep, grep -E '.{0,60}(x).{0,60}' empty.txt on a 0-byte file grows RSS steadily (~440 MB after 6 s, still climbing) until killed; grep -oiE '(BlackRock[^<]{0,80}(Income|American)[^<]{0,40})' on a 44-byte file reached ~860 MB at 16 s with no sign of finishing. System GNU grep answers both in under 10 ms with flat memory. Small bounds ({0,5} × {0,5}) finish instantly, matching the product-of-bounds analysis above. #87238 and #87319 are the same failure on 2.1.233.

🤖 Generated with Claude Code

---
_Generated by Claude Code_

Humper · 13 days ago

Confirming the same blow-up from #87238 (closed as a duplicate of this), with a few data points that extend the picture:

Environment: Claude Code v2.1.233 (npm install, node v22.23.2), Debian 12 in an unprivileged Proxmox LXC, cgroup-v2 memory limit on the container.

Two kills, same trigger: 2026-08-13 02:50 UTC (prior binary version) and 2026-08-17 01:58 UTC (v2.1.233). Both times the dying Bash tool call was a bounded-repetition grep -o -i (alternation + bounded quantifiers) over session-transcript JSONL with ~1.2 MB single lines. In-session the command just looked like a 2-minute timeout; meanwhile the helper kept allocating:

kernel: oom-kill:constraint=CONSTRAINT_MEMCG,...,oom_memcg=/lxc/106,
        task_memcg=/lxc/106/ns/system.slice/claude-launch.service,task=claude.exe,pid=2385553
kernel: Memory cgroup out of memory: Killed process 2385553 (claude.exe)
        total-vm:12915576kB, anon-rss:11639608kB, file-rss:4kB

So with more headroom than the OP's 11 GiB box, the leak just grows further — 11.6 GB anon RSS before our container's 16 GiB memcg stopped it. No convergence at this scale either.

Searchability note: the victim comm here is claude.exe — the re-exec'd claude binary before it sets its process title (the OP's shows as the version string 2.1.220). Anyone grepping kernel logs for this bug should match both.

Mitigation for the collateral-kill half: the OP lost the whole session to OOMPolicy=stop failing the tmux scope. KillMode=process on the unit that owns the sessions works as an alternative to the OOMPolicy=continue drop-in: systemd still fails the unit when the helper is OOM-killed, but the interactive claude processes survive and the unit restarts under them. Both of our kills were session-survivable this way.

+1 to the OP's proposed fix shape: a hard memory ceiling on the emulation with fallback to command grep — failing one tool call is strictly better than a multi-GB allocation race against whatever else shares the cgroup.

Showing cached comments. Read the full discussion on GitHub ↗