Bundled ugrep allocates without bound on '.{0,80}X.{0,80}' — OOM'd a 16 GB machine from a 12-byte file
Summary
The grep shell function installed by Claude Code's shell snapshot re-execs the bundled ugrep. On a pattern with a wildcard bounded repetition on both sides of a literal — e.g. .\{0,80\}foo.\{0,80\} — bundled ugrep allocates without bound and never stops. On a 16 GB machine this reached 12.6 GB, took an OOM kill, and contributed to a hard lock that needed a manual power cycle.
GNU grep runs the identical pattern in 0.02 s using 5 MB.
The memory is consumed at pattern-compile time, so the file being searched is irrelevant — a 12-byte file reproduces it exactly.
Version
2.1.220 (current latest on npm at time of filing). Native install, Linux x86_64, Ubuntu.
Reproduction
Inside a Claude Code session, in a Bash tool call:
printf 'hello world\n' > /tmp/t.txt
grep -o -i '.\{0,80\}world.\{0,80\}' /tmp/t.txt
Memory climbs at roughly 150 MB/s and does not stop. Expected: prints hello world instantly.
Equivalently, driving the bundled binary directly the way the shim does:
CC=~/.local/share/claude/versions/2.1.220
( exec -a ugrep "$CC" -G -o -i '.\{0,80\}world.\{0,80\}' /tmp/t.txt )
Measurements
All against the same 479 KB input unless noted, peak RSS:
Bound size — roughly 4x per +5:
| pattern | peak |
|---|---|
| .{0,10}X.{0,10} | 0.4 MB |
| .{0,20}X.{0,20} | 9.9 MB |
| .{0,25}X.{0,25} | 17.1 MB |
| .{0,30}X.{0,30} | 50.7 MB |
| .{0,35}X.{0,35} | 193.2 MB |
| .{0,40}X.{0,40} | 787.2 MB |
| .{0,50}X.{0,50} | unbounded |
| .{0,80}X.{0,80} | unbounded (12.6 GB observed before OOM kill) |
Both sides are required — either half alone is harmless:
| pattern | peak |
|---|---|
| .{0,80}X.{0,80} on a 12-byte file | >1 GB, killed by test guard |
| .{0,80}X.{0,80} on a 479 KB file | >1 GB, killed by test guard |
| .{0,80}X (leading only) | 8.2 MB |
| X.{0,80} (trailing only) | 0.0 MB |
Regex flavour — -G is what the shim adds, but it is not the cause:
| flags | peak |
|---|---|
| -G (basic) | 1030 MB, blows up |
| -E (extended) | 1028 MB, blows up |
| default (no flavour flag) | 1031 MB, blows up |
| -P (PCRE) | 4.1 MB, ok |
| system GNU grep | 0.3 MB, ok |
Only PCRE escapes, which is consistent with a DFA state explosion in the default engine — PCRE backtracks rather than materialising the automaton.
Why this is worth fixing rather than documenting
- The substitution is invisible. A user types
grepand gets a different program with a materially different memory profile on the same input. Nothing in the session indicates the swap happened. - The pattern shape is a common idiom.
.{0,N}X.{0,N}is the standard way to get a character-level context window around a match whengrep -Cis useless, which is exactly the case for XML, JSON, minified JS, and config dumps — files with no meaningful line breaks. Agents write this shape frequently. - Small-input testing gives a false all-clear. Because the cost is at compile time, the usual "try it on a small file first" instinct does not protect anyone. It fails identically on 12 bytes.
- The failure mode is the whole machine, not the command. By the time the OOM killer fires, other processes have already been stalled in reclaim.
Suggested fixes, roughly in order of preference
- Cap the DFA construction in the bundled ugrep and fall back to PCRE (or to the system
grep) when the bound exceeds some threshold. Empirically anything above ~40 is dangerous. - Pass
-Pby default from the shim, or add it when the pattern contains a bounded repetition over a wildcard. - Run the bundled search tools under an
RLIMIT_AS/RLIMIT_DATAso a pathological pattern kills the subprocess rather than the host.
Any one of these would have turned a machine-wide hard lock into a single failed command.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗