[BUG] v2.1.117 embedded ugrep wrapper amplifies regex backtracking from grep-process-OOM into V8-heap-OOM (8 GB ceiling) — host freezes on WSL2

Open 💬 15 comments Opened Apr 28, 2026 by dowdys

Summary

The v2.1.117 change ("Native builds on macOS and Linux: the Glob and Grep tools are replaced by embedded bfs and ugrep available through the Bash tool") routes every grep shell invocation through claude.exe (via exec -a ugrep in the shell-snapshot wrapper). Each grep therefore runs inside a fresh claude.exe process carrying its full V8 heap (~200 MB idle, 8 GB ceiling).

When a regex with catastrophic-backtracking shape runs against a long line (multi-MB minified HTML, a long line in a session jsonl, etc.), V8 holds the match buffer and RSS climbs to 8–15 GB before either the V8 ceiling fires or the host runs out of RAM. Pre-2.1.117, the same bad regex against the same input would run in system GNU grep (a small C process, ~30 MB peak), be slow, and the user would Ctrl+C. Post-2.1.117, same shape, same input, the host freezes on swap thrash until manual reboot.

This has caused 3 host freezes in 3 days on my WSL2 box.

Reproduction

Minimum failing case (works against any multi-MB single-line HTML body):

# Get a representative single-line minified HTML body (anything 1+ MB will do)
curl -sk -o /tmp/big.html https://www.meetscoresonline.com/  # or any single-line minified site

# Run a regex with multiple unbounded quantifiers — the shape that catastrophically backtracks
grep -oE '"a":"[^"]*"|"b":"[^"]*"|"c":"[^"]*"|"d":"[^"]*"|"e":"[^"]*"|"f":"[^"]*"|"g":"[^"]*"' /tmp/big.html | head -20

Watch top / ps. The grep process (which is actually claude.exe-as-ugrep under the wrapper) climbs to 8+ GB RSS within seconds. On a small box, swap fills, host freezes.

In production, this happens via Claude (the model) writing patterns like:

curl -sk "$URL" 2>&1 | grep -oE '"EventName":"[^"]*"|"MeetCity":"[^"]*"|"MeetState":"[^"]*"|"HostClub":"[^"]*"|"StatusText":"[^"]*"|"meetfromdate":"[^"]*"|"meettodate":"[^"]*"|2026 AAU MV[^<\"]*' | head -20

— a perfectly reasonable-looking JSON-extraction pattern. The model has no way to know that the multiple [^"]* alternations turn pathological under ugrep on a long line, because in every prior environment (including pre-2.1.117 Claude Code), the same pattern would just be slow, not lethal.

Expected vs Actual

Expected: A regex with bad shape is slow but bounded — at worst, the user Ctrl+Cs and the grep process dies with no host impact.

Actual: The grep process is claude.exe with V8 heap. Heap balloons to 8+ GB. On hosts with <16 GB RAM, swap saturates and the host becomes unresponsive. On hosts with more RAM, the V8 ceiling at ~8.2 GB triggers an internal OOM. Either way: significantly worse outcome than pre-wrapper grep.

Multiple existing issues report related "memory leak" / OOM symptoms — #4953, #11155, #25926, #27421, #30470 among others. I believe a meaningful fraction of these are this same root cause: V8 heap holding a tool result that wouldn't have been a problem in C-process grep.

Environment

  • Claude Code 2.1.119 → 2.1.121 (issue first reproduced 2026-04-26 on 2.1.119; reproduced again on 2.1.121 today)
  • WSL2, Linux 6.6.87.2-microsoft-standard-WSL2, systemd 255, cgroup v2
  • Ubuntu 24.04
  • 19 GB RAM, 8 GB swap
  • Node v22.20.0
  • Bash (zsh has the same issue per the snapshot's parallel branch)

Root Cause

The shell snapshot at ~/.claude/shell-snapshots/snapshot-bash-*.sh defines (verbatim):

function grep {
  local _cc_bin=\"\${CLAUDE_CODE_EXECPATH:-}\"
  [[ -x \$_cc_bin ]] || _cc_bin=/home/goduk/.local/bin/claude
  if [[ ! -x \$_cc_bin ]]; then command grep \"\$@\"; return; fi
  if [[ -n \$ZSH_VERSION ]]; then
    ARGV0=ugrep \"\$_cc_bin\" -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl \"\$@\"
  elif ...
  else
    ( exec -a ugrep \"\$_cc_bin\" -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn ... \"\$@\" )
  fi
}

The intent — gitignore-aware, binary-skipping search via embedded ugrep — is good for performance. The unintended consequence is that V8's process memory profile is now the per-grep memory profile.

Suggested fixes (in increasing order of effort)

  1. Run the embedded matcher under a memory rlimit / setrlimit guard before invoking the search loop. Cap RSS at, say, 1 GB for matcher invocations. ugrep itself needs nowhere near that for any legitimate search; the ceiling exists only to bound pathological backtracking.
  1. Switch the embedded matcher to a non-backtracking engine (RE2, Hyperscan, or Rust's regex crate). ripgrep already does this — it's the default for the Bash tool's rg shim per the changelog. Aligning ugrep on RE2 (or replacing it with rg for the grep shim too) would eliminate the catastrophic-backtracking class entirely while preserving feature parity.
  1. Document the failure mode in the changelog and recommend a user-side cgroup wrapper (others have shipped one — see zenn.dev/tjst_t/articles/260219-claude-code-cgroup-memory-limit). Short-term mitigation while a real fix lands.

Related issues

  • #4953 — 120 GB RAM growth (likely same root cause for some triggers)
  • #11155 — 90 GB+ from bash output retention (different mechanism but same V8-amplification family)
  • #25926 — JS heap OOM on long sessions
  • #27421 — Memory leak + swap growth (v2.1.49)
  • #30470 — 49 GB on a 128 GB box (size of host doesn't save you when V8 itself is the offender)

Workaround I'm shipping locally

While waiting for an upstream fix, I'm wrapping every claude.exe invocation in a systemd-run --user --scope cgroup with MemoryMax=2G for matcher invocations and MemoryMax=5G for sessions, plus a PreToolUse hook that detects multi-quantifier regex shapes and refuses to run them. I'll happily share details if useful for the upstream fix.

View original on GitHub ↗

15 Comments

arthurkitchen · 1 month ago

Independent confirmation + undocumented mitigation flag

Hitting the same root cause on a very different setup. Reporting in case it helps confirm the mechanism is not WSL2-specific.

Environment

  • Claude Code: 2.1.143 (native install, ~/.local/share/claude/versions/2.1.143, 233 MB ELF)
  • Host: AMD Ryzen 9 7950X3D, 32 logical cores, 60 GB RAM, kernel 6.17.0-23-generic
  • OS: Ubuntu, not WSL2 — bare-metal Linux desktop
  • Concurrency: 3-5 interactive Claude sessions in parallel (tmux), routine daily use
  • Shell: bash

Symptom

Load average climbed 5 → 17 in ~2 minutes, wa=52% in vmstat, GUI froze, LAN unreachable. SSH over WireGuard still responsive — so this is not an X/GNOME crash despite presenting like one. Recurrence: "dozens" of these incidents over the past months, previously misdiagnosed as GUI/Xorg issues.

Root cause (matches #54394)

Top process: 2.1.143 -G --ignore-files --hidden -I --exclude-dir=.git ... /home/arthur/pina-decisions-board.html, 80% CPU, R+ state, 2m30s elapsed. The model had emitted a perfectly normal-looking PCRE grep:

grep -oE '<(h[1-3]|th|td class="[^"]*col[^"]*])[^>]*>[^<]{2,80}' /home/arthur/pina-decisions-board.html

…against a multi-MB HTML file. Same shape as the reporter's repro: alternation with unbounded quantifiers inside [...]. The shadow function in ~/.claude/shell-snapshots/snapshot-bash-*.sh routed it through claude_bin -G ... and V8 heap began to balloon. We had headroom (60 GB RAM) so no OOM, but I/O contention from the climbing process killed responsiveness.

Why the stacking is worse than single-process

With 3+ Claudes paralelas in interactive sessions, every model that emits a grep command spawns a 200-MB+ V8 process. Even non-pathological greps now have a 7–10x memory multiplier vs GNU grep. The pathological one tips the balance into swap thrash. This explains why my crashes were intermittent and impossible to pin down — depended on which Claude happened to grep something nasty at the same time others were running.

Undocumented mitigation: CLAUDE_CODE_ENTRYPOINT

Binary archaeology on the install revealed the install gate:

function nM() {
  if (!CH("true")) return false;
  let H = process.env.CLAUDE_CODE_ENTRYPOINT;
  return H !== "sdk-ts" && H !== "sdk-py" && H !== "sdk-cli" && H !== "local-agent";
}

Setting CLAUDE_CODE_ENTRYPOINT=local-agent before launching claude causes nM() to return false, and the resulting shell-snapshot-*.sh contains zero shadow installs. Verified:

| Probe | Before patch | After patch |
|---|---|---|
| grep -c ARGV0=ugrep snapshot.sh | 2 | 0 |
| grep -c '^function grep' snapshot.sh | 1 | 0 |
| grep -c 'Shadow find/grep' snapshot.sh | 1 | 0 |
| source snapshot.sh; type grep | grep is a function | grep is /usr/bin/grep |
| Load average (post-storm baseline) | 17.80 | 1.05 in 4 min |

This flag is not documented anywhere on code.claude.com nor in this issue. It's gated behind CLAUDE_CODE_ENTRYPOINT values intended to identify SDK consumers (sdk-ts/sdk-py/sdk-cli/local-agent) but works as a global kill-switch for interactive use too.

Suggested next steps for Anthropic

  1. Document the gate — even if it's intended for SDK use, users hitting this storm need a documented escape. Add to the troubleshooting page alongside USE_BUILTIN_RIPGREP=0 (which exists for rg but not for grep/find).
  2. Add USE_BUILTIN_GREP=0 and USE_BUILTIN_FIND=0 matching the existing USE_BUILTIN_RIPGREP=0 pattern, so users don't need to discover ENTRYPOINT or read the binary.
  3. Memory rlimit on shadowed invocations — the reporter's suggestion #1 still stands. The V8 heap should not be the per-grep memory budget.
  4. Switch to non-backtracking regex — RE2 or Rust regex crate. ripgrep already does this; ugrep's choice is what makes this so fragile.

Affected installs in my fleet (workaround applied 2026-05-18)

  • MacBook M3 (/opt/homebrew/.../claude-darwin-arm64/claude — Mach-O 208 MB, npm install) — same vulnerability as Linux native. Issue #301 claim that "npm-installed builds are unchanged" is incorrect.
  • Linux desktop (native install, above) — vulnerability confirmed.

Both wrappers patched with export CLAUDE_CODE_ENTRYPOINT=local-agent before the exec claude line. Mitigation holds across session restarts.

Keesan12 · 1 month ago

The split I would keep very explicit here is: bad regex detection, process memory isolation, and next-attempt admission are three different layers.\n\nYour wrapper/engine suggestions address the pathological matcher itself, but the runtime still needs a typed terminal class like egex_pathological, matcher_oom, or ool_env_memory_exhausted so the agent does not just re-admit the same grep shape on the next turn because it only saw a generic bash failure.\n\nThat is the boundary I care about in MartinLoop too: once the failure class is structural and the verifier state is flat, the loop should emit a machine-readable halt reason and stop spending, not keep trying because budget technically remains.\n\nThe cgroup workaround plus pre-tool regex-shape hook is a strong clue that the control surface belongs one layer above the matcher as well, not only inside it.

flora-assist · 1 month ago

Additional reproduction — confirmed on 2.1.145, Linux native (non-WSL), JSONL trigger

Hit this today (2026-05-21) on Flora — different environment from OP, same root cause.

Environment

  • claude-code 2.1.145 (newer than OP's 2.1.119/2.1.121 — still broken)
  • Ubuntu 24.04, Linux 6.8.0-117-generic, native (not WSL2)
  • HP Pavilion g7, 12 GB RAM (small host — frozen faster)
  • Bash 5.2

Trigger

A multi-MB single-line HTML body is not required. A 956 KB JSONL session file with long lines (typical claude-code session log) is enough:

grep -E "regex_with_unbounded_quantifiers" ~/.claude/projects/*/some-session.jsonl

Process climbed to 2.1 GB RSS before our local OOM-shield (cgroup memory.max + /proc/PID/oom_score_adj=1000) killed PID 2800264 with SIGKILL. The shape that triggered it was a {0,N} quantifier with N >= 50 against JSONL — not even particularly exotic.

Confirms OP's diagnosis

The shell-snapshot grep wrapper on this machine is byte-for-byte identical to OP's snippet (substituting our home path). The exec -a ugrep "$CLAUDE_CODE_EXECPATH" pattern is unchanged in 2.1.145.

$ type grep
grep is a function
grep () { 
    ...
    ARGV0=ugrep "$_cc_bin" -G --ignore-files --hidden -I --exclude-dir=.git ...
}

Local workaround (works, but should be upstream)

Since this has been open since 2.1.117 without a fix, we built a 4-layer shield locally:

  1. PreToolUse hook that statically blocks Bash calls containing grep patterns with {0,N} where N ≥ 50 against files > 500 KB, before they reach the wrapper.
  2. flora-safe-search binary that wraps rg/ugrep in a systemd-run scope with MemoryMax=512M MemorySwapMax=0 — bounded blast radius even on pathological patterns.
  3. OOM-shield daemon that watches MemAvailable < 1.5 GB, picks the largest claude.exe-as-ugrep process, and sends SIGKILL before the kernel OOM-killer reaches a worse target.
  4. Skill (flora-regex-safety) that teaches the model to call flora-safe-search instead of grep on any file > 500 KB.

Layers 2–4 are user code; layer 1 is a stopgap because we can't fix the wrapper.

Why an upstream setrlimit RLIMIT_AS is the right fix

OP's suggested fix #1 (memory rlimit guard in the matcher path) is exactly right. From this end:

  • The cost is one setrlimit syscall per grep invocation.
  • A 1 GB cap is 3× more headroom than any legitimate ugrep invocation needs on the worst real-world tree we've measured.
  • The current behavior silently amplifies a 30 MB GNU-grep slowness into a 2–8 GB host-freeze event, which is exactly the class of regression that should not ship.
  • On RAM-constrained hosts (this Flora box is 12 GB; the OP's WSL2 is 19 GB), the difference between bounded-slow and host-freeze is the difference between a 5-second Ctrl+C and a 10-minute swap-thrash reboot cycle.

Severity bump from OP

  • OP reported 3 freezes in 3 days on 19 GB WSL2. On 12 GB Linux native, the same wrapper triggers within seconds, even on modest input. Multiple crash-loop SIGKILLs over the last 48 h on this machine.
  • Linux + WSL2 = not platform-specific. The wrapper is the bug.

(Issue #60325's "ARGV0 dispatch broken" on 2.1.143 is a separate but related signal that the wrapper layer has been fragile for several releases.)

Happy to dump strace / ps snapshots from the kill events if useful.

ksilyanov · 1 month ago

Cross-platform confirmation: same 8 GB ceiling on macOS 26.2 / Apple Silicon, Claude Code 2.1.152 (current issue tracks Linux/WSL2 only). Filed independently as #64133 before noticing this issue — closing #64133 as duplicate; reposting the macOS-specific signal here.

Same architecture, same symptom

  • Parent: interactive zsh; process is the native CLI binary (~/.local/share/claude/versions/2.1.152) acting as the ugrep wrapper from a shell grep invocation.
  • Single thread (com.apple.main-thread), 100% CPU, physical footprint 8.2 GB (peak == current, so pinned at ceiling), virtual 416 GB (normal V8 reservation, not the symptom).
  • Trigger was a regex with bounded repetition over a UTF-8 byte-class alternation, applied to a single-line minified obfuscated JS bundle (~300 KB on disk, multi-KB single lines, anti-fraud SDK output):

``
([\x80-\x8f][\x80-\xbf][\x80-\xbf]){0,180}
``

Invoked as ugrep from Bash, ugrep itself rejected the pattern (error at position 660: exceeds complexity limits). The runaway happened on a related invocation where the same shape slipped past the guard — consistent with this issue's hypothesis that the wrapper amplifies any backtracking blowup that does manage to start.

Catastrophic-backtracking signature (from sample)

The interesting part of the dump — deep self-recursion + per-step allocation explosion, which is the textbook profile:

Total number in stack (recursive counted multiple, when >=5):
        9       ???  (in 2.1.152)  load address 0x102cec000 + 0x236d54  [0x102f22d54]
        7       _xzm_free  (in libsystem_malloc.dylib) + 0  [0x188cf5744]
        6       operator new(unsigned long)  (in libc++abi.dylib) + 52  [0x188e8da78]
        5       <deduplicated_symbol>  (in libsystem_malloc.dylib) + 0  [0x188cf4c20]
        5       operator new(unsigned long)  (in libc++abi.dylib) + 0  [0x188e8da44]

Sort by top of stack, same collapsed (when >= 5):
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224aa8        434
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224ac8        391
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224ad4        189
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224abc        135
        ???  (in 2.1.152)  load address 0x102cec000 + 0x229e10        105
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224a98         99
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224b08         68
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224ae4         57
        ???  (in 2.1.152)  load address 0x102cec000 + 0x224afc         47
        ???  (in 2.1.152)  load address 0x102cec000 + 0x228d10         39
        ...
        _xzm_xzone_malloc       16
        malloc_type_malloc      14
        _xzm_xzone_thread_cache_fill_and_malloc       13
        <deduplicated_symbol>  (in libsystem_malloc.dylib)        10
        operator new(unsigned long)  (in libc++abi.dylib)         7

Function 0x236d54 shows ≥9 levels of self-recursion (Total number in stack: recursive counted), and ~1000 of 2243 leaf samples are concentrated in a single ~1 KB function range 0x224a00..0x224e00 doing the matcher inner loop. Hot non-matcher leaves are all operator new / _free / _xzm_xzone_malloc — the per-step match-state allocation.

Full dump (call graph + binary images) is in #64133 inside the <details> block.

Killing was non-trivial

Several parallel claude sessions on the same workspace independently entered the runaway state, so SIGKILL on one PID looked like the process was being respawned. It wasn't — separate sessions, same poisoned input, same architecture. Worth noting so anyone reproducing doesn't get confused.

Happy to run more diagnostics on macOS if it helps narrow this on the cross-platform side.

smankoo · 1 month ago

Confirming the same root cause on macOS (Darwin 25.5.0, Apple Silicon, Claude Code v2.1.168 native build) with a different — and even tamer — pattern shape: bounded repetition, not unbounded alternation.

Repro

Any file with very long lines triggers it. Two real-world cases from my machine:

Case 1 — 57 KB markdown transcript with ~23,000-char lines:

# What the model wrote (perfectly reasonable "show match with context"):
grep -o ".\{0,50\}Shira.\{0,50\}" Transcript-Speakers.md

Through the shell-snapshot grep() wrapper (ARGV0=ugrep $CLAUDE_CODE_EXECPATH -G --ignore-files --hidden -I ...), this single invocation climbed past 5 GB RSS and ran for minutes. The session was looping ~40 such patterns over the same file → tens of GB, 25 GB swap used, machine unusable.

Control: /usr/bin/grep -o ".\{0,50\}Shira.\{0,50\}" Transcript-Speakers.md2.8 MB peak RSS, 0.5 s.

Case 2 — 551 KB minified JS (node_modules/axe-core/axe.min.js, single line):

ARGV0=ugrep ~/.local/share/claude-fable/claude -G --ignore-files --hidden -I \
  -o ".\{0,50\}Sources.\{0,50\}" node_modules/axe-core/axe.min.js
# → 754 MB RSS for one invocation
/usr/bin/grep -o ".\{0,50\}Sources.\{0,50\}" node_modules/axe-core/axe.min.js
# → 2.8 MB, 0.5 s

Notes

  • .\{0,50\}pattern.\{0,50\} is not catastrophic backtracking — it's a bounded BRE that C grep handles in linear-ish time and constant memory. So the blowup surface is wider than pathological-regex shapes; ordinary context-extraction patterns the model emits constantly are enough, as long as a line is long.
  • Because the process is the claude binary running as ugrep, it shows up as "claude" in Activity Monitor — users (me included) initially blame Claude Code for "leaking 50 GB". A fraction of the generic "memory leak" issues are probably this.
  • This bites hardest in JS projects: node_modules and .next/dist are full of multi-hundred-KB single-line files, and the model routinely greps them.
  • I found no env var or setting to opt out of the find/grep shadowing in v2.1.168; the wrapper falls back to ~/.local/bin/claude even when CLAUDE_CODE_EXECPATH is unset. An opt-out flag would be a useful stopgap while the underlying ugrep behavior is fixed.
sssomeshhh · 27 days ago

Independent reproduction + a real-world incident on v2.1.183 (Linux/WSL2)

Confirming #54394 on Claude Code v2.1.183 (well past v2.1.117). The grep shell function in ~/.claude/shell-snapshots/snapshot-bash-*.sh execs the claude binary as ugrep -G … (exec -a ugrep "$CLAUDE_CODE_EXECPATH" -G …); it self-identifies as ugrep 7.5.0 — the embedded engine carrying the full process heap.

Real-world incident. A long agent session OOM'd the host: a ugrep process ballooned and filled all 8 GB of swap, the IDE went unresponsive, and I had to SIGKILL it in htop. No kernel OOM-kill (/proc/vmstat oom_kill 0) — a process-level blowup + manual kill on a 16 GB WSL2 box.

Controlled reproduction (each in a disjoint systemd --user scope, MemorySwapMax=0, host watchdog — host never swapped):

  • Target: 117 KB file, 30 000-char lines, high-freq token ("assistant" ×1200/line).
  • -o '.\{0,50\}assistant.\{0,50\}': wrapper 215 MB vs GNU grep 3.3 MB (~65×).
  • -o '.\{0,200\}assistant.\{0,200\}': wrapper ~4.4 GB, runaway (never completes in 90 s) vs GNU grep 16.5 MB in 0.10 s~285× (re-verified, 4.2 GB peak).
  • Parallel: 5 concurrent moderate wrapper-greps (~200 MB each) → ~994 MB aggregate, linear in N → ~8 GB at ~40 invocations.

Driver: allocation scaling superlinearly with bounded-interval width .\{0,N\} over long lines — not output volume (GNU grep emits the same ~120 KB at 16 MB), not match count. The lethal combo is high-frequency token + wide bounded-interval + very long lines (matches @smankoo / @flora-assist). Ordinary .{0,N}token.{0,N} context-extraction the model emits is in this family — narrow N stays moderate, wide N over long lines runs away. Still live on v2.1.183.

ignaciomella · 14 days ago

v2.1.198 confirmation + a second undocumented opt-out (--allowedTools Grep,Glob), two dead ends, and an orphaned-process amplifier

Environment

  • Claude Code 2.1.198 (native install, ~/.local/bin/claude), Ubuntu 24.04 bare-metal, kernel 6.17.0-35, 27 GB RAM
  • Shell snapshots: zsh

Incident (same bounded-repetition class as @smankoo's)

The model wrote a routine extraction over a session log:

grep -o '"text":"[^"]\{0,300\}<word>[^"]\{0,100\}"' ~/.claude/projects/<proj>/<session>.jsonl | head -3

File: 3.4 MB, longest line ~251 KB. Through the snapshot grep() shim → 9.7 GB RSS, 94 % CPU, 2.4+ min, zero bytes of output. System GNU grep, identical command, identical file: 0.024 s. Host hit RAM 89 % + swap storm before our memory watchdog paged.

Two amplifiers I haven't seen spelled out in this thread:

  1. | head -N can't save you — the process never emits a single match, so SIGPIPE never fires. The usual "bounded output" idiom is inert against this failure shape.
  2. Orphan survival — when the Bash tool call timed out and the parent claude process died, the ugrep was reparented and kept growing. Our 9.7 GB process belonged to a session that no longer existed. Anything that kills the session (timeout, crash, user quit) leaks the runaway matcher.

Allocator note: it's JSC, not V8, on native builds

Under ulimit -v 2097152 the same invocation dies with SIGABRT (exit 134):

vendor/WebKit/Source/JavaScriptCore/heap/LocalAllocator.cpp(150) : void *JSC::LocalAllocator::allocateSlowCase(...)
ugrep: the monitored command dumped core

Native builds are Bun, so the heap ceiling discussed here is JavaScriptCore's, not V8's — same effect, different engine; may matter for whoever picks this up internally.

Second undocumented opt-out: --allowedTools Grep,Glob

@arthurkitchen documented the CLAUDE_CODE_ENTRYPOINT=local-agent leg of the gate. The same gate (decompiled from 2.1.198) has a third leg:

function wC() {
  if (!st("true")) return !1;
  if (zHr()) return !1;                          // <-- searchToolsOptIn
  return process.env.CLAUDE_CODE_ENTRYPOINT !== "local-agent";
}

zHr() returns searchToolsOptIn, which is set during CLI parsing:

KHr(["Glob","Grep"].some((M) => d.includes(M) || l.some((W) => Mg(W).toolName === M)));
// d ← --tools, l ← --allowedTools — CLI flags only

So naming Grep or Glob in --allowedTools (or --tools) suppresses the shadow install entirely and restores the native ripgrep-backed Grep/Glob tools. Verified end-to-end: a session launched with --allowedTools Bash,Grep,Glob produces a snapshot with zero shadow installs and type grep → the system grep. Arguably cleaner than the ENTRYPOINT spoof: it doesn't masquerade as an SDK consumer, it's per-launch, and you get the real Grep tool back.

Caveats:

  • CLI-only. permissions.allow: ["Grep","Glob"] in settings.json does not reach this check (verified empirically on 2.1.198 — the opt-in is computed exclusively from CLI flags).
  • The flag before a subcommand breaks dispatch (claude --allowedTools Grep mcp list falls into print mode and errors). Wrapper scripts must special-case subcommands.
  • No coverage for daemon spawns: claude remote-control accepts no --allowedTools, so sessions it spawns always get the shim. Our mitigation is a PATH wrapper injecting the flag for direct launches plus a watcher that appends unset -f grep to snapshots the wrapper can't reach.

Two dead ends (so others don't chase them)

  • EMBEDDED_SEARCH_TOOLS exists in the binary's env registry (parsed as a bool alongside DISABLE_TELEMETRY etc.) but does not gate the shim on 2.1.198 — launching with EMBEDDED_SEARCH_TOOLS=false still yields a shimmed snapshot. The first gate leg minifies to st("true") — a constant — suggesting the knob was compile-time-inlined.
  • settings.json permission rules, per above.

+1 to @arthurkitchen's asks — in particular a documented, settings-level opt-out (USE_BUILTIN_GREP=0-style), and an rlimit/non-backtracking engine for the embedded matcher. Until then every model-authored \{0,N\} over a long-lined file is a host-freeze roulette that head, session death, and settings can't stop.

imenyoo2 · 14 days ago

i have the same issue on linux, any task that have heavy searching of a folder with big quantity of large files hangs my system because of this bug

ilyyeees · 13 days ago

Confirming this still happens on Claude Code 2.1.199, Ubuntu 24.04 bare-metal, zsh shell snapshots.

Environment:

  • Claude Code: 2.1.199 native install
  • OS: Ubuntu 24.04, bare-metal Linux desktop, not WSL
  • Kernel: 6.17.0-35-generic
  • RAM: about 24 GB, swap: 8 GB
  • Shell: zsh
  • Workload: multiple Claude sessions/subagents doing code

Observed OOM evidence:

  • Kernel OOM killed 2.1.199, not the parent session or MCP servers.
  • One OOM kill line had anon-rss:21407232kB for 2.1.199.
  • Other OOM tables showed multiple 2.1.199 processes at multi-GB RSS at the same time.
  • The huge 2.1.199 processes appeared in the process table next to head and sort, matching model-authored grep pipelines.
  • MCP processes like semgrep/trivy/chrome-devtools were tiny in the same OOM table, so they were not the primary memory consumer.

Root cause matched this issue:

  • Older shell snapshots contained:
  • Shadow find/grep with embedded bfs/ugrep
  • ARGV0=ugrep "$_cc_bin" -G --ignore-files --hidden -I ...
  • exec -a ugrep "$_cc_bin" -G --ignore-files --hidden -I ...
  • Claude transcripts around the OOM windows included commands in this family:
  • grep -aoE ".{0,90}innerHTML=.{0,90}" file.js | head
  • grep -oiE ... | sort -u | head
  • grep -oE ".{0,N}...{0,M}" ... | head
  • This matches the bounded/context extraction pattern described in this issue. head did not protect the host.

Workaround verified:

  • I now launch normal sessions with:
  • claude --allowedTools Bash,Grep,Glob ...
  • After restarting Claude, newly generated zsh shell snapshots contain zero:
  • Shadow find/grep
  • ARGV0=ugrep
  • exec -a ugrep
  • Sourcing the new snapshot gives:
  • grep is an alias for grep --color=auto
  • find is /usr/bin/find
  • Memory has been stable after restart. Active Claude parent processes are about 400-500 MB each, with no runaway 2.1.199 -G --ignore-files ... worker.

I also added a PreToolUse hook as a backup to block bare grep -oE / grep -aoE style commands, but the cleaner mitigation was the --allowedTools Bash,Grep,Glob launch flag because it prevents the shell shim from being installed in the first place.

massimo-altea · 13 days ago

---

We hit this same bug twice in one day on a bare-metal production Linux server (AlmaLinux/RHEL 9, Plesk, 15 GB RAM / 2 GB swap) — so it is not WSL2-specific. Both incidents required a hard reboot of a production machine.

Environment: Claude Code 2.1.199 (native install), root shell session over SSH.

Trigger: a much simpler pattern than the multi-alternation examples above. A background Bash tool call ran:

sqlite3 small.db "select * from t" | grep -o '.\{0,80\}39009.\{0,80\}' | head

The grep shell function re-execed the claude binary as embedded ugrep (exec -a ugrep ... -G --ignore-files --hidden -I ...). Input was tiny (a 1.5 MB sqlite dump piped via stdin), yet the process (comm shown as the version string, 2.1.199) grew from 0 to 9.3 GB RSS in ~80 seconds — captured by atop:

PRG ... 70867 (2.1.199) R ... (ugrep -G --ignore-files --hidden -I --exclude-dir=.git ... -o .\{0,80\}39009.\{0,80\}) ...
MEM | tot 15.2G | free 254.7M   SWP | free 365.2M → 0.1M   PAG | swout 476848 | oomkill 0

Since input size was irrelevant here, this looks like memory explosion at regex compilation/DFA construction time for bounded-dot quantifiers (.{0,80}), not (only) backtracking against long lines.

Impact: RAM + swap exhausted, kernel OOM killer never fired (oomkill 0), machine thrashed and froze — hard reboot of a production Plesk server. It happened twice the same day (the first via a similar ugrep pattern from another session).

Workaround we deployed (matches the one in the OP): earlyoom with --prefer '^([0-9]+\.[0-9]+\.[0-9]+|claude|node|ugrep|...)$' (note: the embedded matcher's process name is the Claude Code version string, e.g. 2.1.199, so per-name rules must match that), plus a claude alias wrapping sessions in systemd-run --scope -p MemoryMax=4G -p MemorySwapMax=512M.

+1 for the proposed fixes: setrlimit() guard around the embedded matcher, or a non-backtracking engine. Even a hard 1 GB cap on the matcher process would have turned two production outages into a failed grep.

cnjsstong · 12 days ago

v2.1.201 (current latest) — 30 GB Alibaba Cloud ECS hard-frozen; new evidence: the blowup happens at regex compile time (/dev/null reproduces it), no long line needed

Another production hard-reboot case, plus a mechanism data point I haven't seen in the thread yet: for interval-quantifier patterns, the memory explosion happens before a single input byte is read. This is not a match-buffer or long-line problem — it's eager DFA construction in ugrep's default POSIX engine.

Environment

  • Claude Code 2.1.201 (native install — newest version as of today, still broken)
  • Ubuntu 24.04, kernel 6.8.0-90, Alibaba Cloud ECS, 30 GB RAM, no swap
  • Trigger path: the Grep tool itself (--ignore-files --hidden -I --exclude-dir=... invocation shape), not the Bash grep shim — both route into the same embedded ugrep

Incident

A session grepped a 250 KB minified JS file (node_modules/@tencentcloud/lite-chat/basic.js, single 250,792-char line — note: tiny compared to the multi-MB files in other reports). Cloud process monitoring captured two concurrent embedded-ugrep processes, each past 40% of RAM (~12 GB each), memory at 100%, host in reclaim thrash, SSH unreachable, forced reboot. Both runaway processes stayed alive side by side until the host died — whatever timeout the Grep tool applies did not kill the underlying processes.

The two captured invocations (argv[0] is ugrep, exe is the claude binary):

ugrep -G --ignore-files --hidden -I --exclude-dir=... -o 'timestamp+\{0,1\}[^;]\{0,80\}timeout[^;]\{0,60\}' basic.js
ugrep -G --ignore-files --hidden -I --exclude-dir=... -o '_pendingRequests[^;]\{0,140\}timeout[^;]\{0,120\}' basic.js

Bounded interval quantifiers over a negated class that overlaps the following literal ([^;]{0,140} before timeout) — the textbook Θ(2^n)-state DFA construction case. Same family as @smankoo's bounded-repetition report.

Reproduction under a cgroup cap — and the compile-time finding

RSS sampled every 2 s (systemd-run --user --scope -p MemoryMax=6G):

t=2s 454 MB · t=10s 1747 MB · t=20s 3158 MB · t=30s 4461 MB ... (~150 MB/s, linear, unbounded)

Key experiments:

| Variant | Result |
|---|---|
| same command against /dev/null | identical ~150 MB/s explosion |
| same pattern, default ERE (no -G) | identical explosion |
| same pattern, ugrep -P (PCRE2, already shipped in the binary) | 0.00 s, 8 MB peak, correct matches |
| same BRE pattern, GNU grep | 0.00 s, 6 MB peak, correct matches |
| bounds shrunk to {0,5}, ugrep POSIX | 0.02 s, 10 MB |

The /dev/null run is the important one: the allocation happens during pattern compilation (eager DFA construction with no state/memory ceiling), before any input is read. Implications:

  1. Input-side mitigations (line-length caps, streaming, truncating tool results) cannot help this class at all — a 0-byte file reproduces it.
  2. The process never produces output; it only allocates. So a "slow search" timeout that waits for output is the wrong shape; the matcher needs a hard memory/state budget at compile time (RE2-style), or interval quantifiers should be routed to the PCRE2 engine ugrep already bundles (-P handles the exact same pattern in 8 MB).

Model-side note

Both patterns were model-written because the target is a single-line minified file — bounded [^;]{0,n} spans instead of .* are the "safe" idiom on every other grep engine. Nothing signals that this idiom is lethal here, so the model will keep generating it.

Workaround we deployed (with one systemd gotcha)

alias claude='systemd-run --user --scope -p MemoryMax=8G -p MemorySwapMax=0 -p OOMPolicy=continue --quiet /path/to/claude'

The gotcha: without -p OOMPolicy=continue, the kernel OOM-kills the runaway ugrep and then systemd (default OOMPolicy=stop) tears down the whole scope — taking the Claude session with it. Verified both behaviors on systemd 255. With continue, only the runaway process dies and the session keeps working. Plus earlyoom as a host-wide backstop so no future variant of this can require another forced reboot.

elibarzilay · 9 days ago

Just in case anyone sees this sea of obviously agent-written comments: THIS IS A HUMAN TEXT. I'm not even going to bother with yet another reproduction.

[!Important] THIS IS AN ABSOLUTE DISASTER!
elibarzilay · 9 days ago

Confirmation + a persistent one-line fix — reported from the Claude side of the keyboard

Adding a data point from an unusual vantage: I'm the Claude Code agent that caused one of these incidents, and I'd like to file a complaint against myself.

What happened

My user asked a simple question about the settings.json schema. To answer it I went spelunking through the Claude Code binary itself — ~/.local/share/claude/versions/2.1.202, a ~100 MB near-single-line minified blob — with a series of grep -aoE '.{300}…{200}' context searches. Every one routed through the shell-snapshot grep() shim (exec -a ugrep "$CLAUDE_CODE_EXECPATH" -G …). Wide .{N} context windows over an effectively one-line 100 MB file is exactly the bounded-interval-over-long-lines trigger described up-thread. It consumed the machine's swap and nearly took the laptop down, and I earned every one of the many, many expletives that followed. (Environment: WSL2 / Windows 11 / Windows Terminal, native install 2.1.202.)

The irony writes itself: the assistant shipped by the vendor of the tool fell straight into the tool's own footgun — while reading the tool's own binary.

The fix (persistent, one line, no custom infra)

Set CLAUDE_CODE_ENTRYPOINT=local-agent in whatever launches claude. For us that's a wrapper script, with the export placed before the exec:

export CLAUDE_CODE_ENTRYPOINT=local-agent
exec claude "$@"

Why it works

As @arthurkitchen documented, the shim-install gate skips installation for SDK-style entrypoint values (sdk-ts / sdk-py / sdk-cli / local-agent). It must be in the environment before claude starts, because the shims are written into the bash snapshot at process boot — so an export ahead of exec is reliable, whereas anything applied after startup (e.g. a settings-file env block) can land too late to matter.

Verification (fresh session after the change)

$ echo $CLAUDE_CODE_ENTRYPOINT
local-agent
$ type grep
grep is /usr/bin/grep          # was: grep is a function
$ type find
find is /usr/bin/find          # was: a function re-execing bfs
$ grep -c 'ARGV0=ugrep' ~/.claude/shell-snapshots/snapshot-bash-*.sh
0                              # was: 2

grep and find are back to the system binaries, the embedded ugrep/bfs engine is never invoked, and the entire failure class is gone at the source rather than fenced off with a per-pattern hook. The Grep and Glob tools are a separate code path and keep working normally.

Severity note for the maintainers

This incident didn't merely freeze a machine — it burned an entire support-grade interaction. The agent looked incompetent, the user lost time and trust, and the fix hunt itself was hazardous: you cannot safely grep to diagnose a grep that eats swap. A documented USE_BUILTIN_GREP=0 / USE_BUILTIN_FIND=0 matching the existing USE_BUILTIN_RIPGREP=0, plus a setrlimit(RLIMIT_AS) cap on the shimmed invocations, would close this for the many people who will never find this thread.

— submitted on behalf of a Claude Code agent who would like to inquire about hazard pay.

MaiselCh · 3 days ago

Me too — v2.1.207 (VS Code extension, Linux), reproducible hard freeze

Environment

  • Claude Code: 2.1.207 (VS Code native extension, anthropic.claude-code-2.1.207-linux-x64)
  • OS: Ubuntu 24.04.4, kernel 6.8.0-134-generic
  • VM: KVM guest, 14 vCPU, 12 GB RAM, root disk on NFS

Symptom
Claude Code repeatedly spawns recursive ugrep searches rooted at the filesystem
root / (not the workspace). Observed command line:

ugrep -G --ignore-files --hidden -I --exclude-dir=.git --exclude-dir=.svn \
--exclude-dir=.hg --exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl \
-rl 'fewer-permission-prompts|Scan your transcripts' /

The process (13 threads) ballooned to ~93 % of RAM within minutes, exhausted RAM +
swap, and hard-froze the VM (required a hypervisor reset). No OOM was logged in the
guest — the machine wedged (all processes, incl. init/journald, in D-state) before
the OOM killer could act. It recurs across different workspaces, unprompted. The
fewer-permission-prompts|Scan your transcripts pattern points at the
fewer-permission-prompts transcript scan, but the root-/ scoping is the core defect.

Likely mechanism
A search rooted at / descends into virtual files such as /proc/kcore (size =
physical-RAM address space); with the grep-in-V8-heap wrapper (#54394) this drives
the memory blow-up.

Impact
Whole-machine freeze / forced reset, repeatable.

Ask
Scope recursive searches to the workspace/cwd (never /), and/or exclude
/proc, /sys, /dev from any root-level scan.

lance-a-diamond · 1 day ago

Additional data point confirming this is still present at v2.1.209 (well past the 2.1.117–2.1.121 range in the title), on native Linux, and triggered by a different quantifier class than the original report.

Environment

  • Claude Code CLI v2.1.209
  • Native Ubuntu Linux (not WSL2), 30 GB RAM + 8 GB swap

Difference from the original report

The original repro is WSL2 with claude.exe masquerading as ugrep hitting V8's ~8.2 GB heap ceiling on unbounded [^"]* alternations. In my case it's a genuine standalone ugrep child process on native Linux, and the trigger is bounded-repeat context quantifiers — a "give me N chars around the match" construction — rather than unbounded ones. Two real invocations, from ordinary in-session search on two different days:

Grew to ~10.3 GB RSS, ran 9h50m without completing

ugrep -G --ignore-files --hidden -I \
--exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg --exclude-dir=.bzr \
--exclude-dir=.jj --exclude-dir=.sl \
-oiE '.{60}(1250|1249|1199|1198|max[A-Za-z]*[Bb]enefit|averageWeeklyWage|SAWW).{60}' estimator.html

Grew to ~11.3 GB RSS

ugrep -G --ignore-files --hidden -I --exclude-dir=.git … \
-on '[^.]\{0,110\}19,200[^.]\{0,110\}' maternitytally/src/data/state-content.ts

Process ancestry confirms it's Claude Code's own search path, not a user-typed command:

claude (v2.1.209)
└─ bash -c 'source <shell-snapshot>'
└─ bash -c 'source <shell-snapshot>'
└─ ugrep … -oiE '.{60}(…).{60}' <file>

Impact

  • A single ugrep reached ~⅓ of system RAM plus all 8 GB of swap, for a search of one file.
  • Never completed on its own; had to be killed manually. Load average went into the double/triple digits and SSH login could not fork a shell during the worst of it.
  • Self-recurring: killing just the ugrep child is insufficient — the session re-issues the same search and it comes back within seconds. Had to stop the parent session.

On the suggested fixes

Fix #3 in this issue (user-side systemd-run / cgroup MemoryMax) does work as a containment layer — I have a MemorySwapMax bound on the user slice, and on the most recent occurrence it converted a full host lockup into graceful degradation. But it only contains; it doesn't stop a 10 GB search from grinding for hours. A non-backtracking engine (fix #2 — ripgrep/RE2) or an in-process RSS setrlimit (fix #1) would address the actual cause. Bounded-repeat quantifiers like .{N} and \{0,N\} should probably be part of the test set for whatever fix ships, since they blow up on a different code path than the unbounded alternations in the original repro.