Bash-tool `grep` shim (ugrep emulation): catastrophic backtracking — 6.6 GB RSS / OOM kill on a 20 KB file
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-*.scopewithOOMPolicy=continueand
MemoryMax=6G, so a runaway helper dies alone instead of taking the agent
session with it.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
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
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 simplytwo 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, whichrules 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
(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.)
curlfollowed a redirect, sosub.htmlwas 15 bytes —Redirecting...\n. Theshim 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.
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:
No
grepand nocurlappear anywhere in the dump. There was also no V8JavaScript heap out of memoryabort, 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=stopfailed 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:
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
vm.swappiness=150,systemd-oomdinactiveGiven that a bare
.{0,60}(x).{0,60}against an empty file is enough to allocatea 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.*Independent confirmation on 2.1.231, with a new failure shape: the scope that
OOMPolicy=stoptore 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
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:
(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=stopthen 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 -arewrites argv[0], the victim'scommis 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.confVerified end to end —
systemd-run --user --scope --unit=app-orca-999999reportsOOMPolicy=continue,DropInPaths=…/app-orca-.scope.d/oom-guard.conf, and the kernel files carry it (memory.max = 21474836480).MemoryMaxkeeps the blow-up cgroup-local so the desktop never thrashes, andOOMPolicy=continueoverrides the default that kills the healthy app alongside it. Requires thememorycontroller delegated touser@.service(default on Ubuntu 24.04).2. The
PreToolUsehook 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/grepandcommand grepbypasses, 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)?grepso the unparseable-command path also catches bareugrep, 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
grepwith an engine that has this failure mode.Happy to provide the full kernel task dumps or the daemon session logs.
Confirmed / reproduced on 2.1.234 (Linux): with the in-session
grep,grep -E '.{0,60}(x).{0,60}' empty.txton 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_
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: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 string2.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=stopfailing the tmux scope.KillMode=processon the unit that owns the sessions works as an alternative to theOOMPolicy=continuedrop-in: systemd still fails the unit when the helper is OOM-killed, but the interactiveclaudeprocesses 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.