Bundled ugrep allocates without bound on '.{0,80}X.{0,80}' — OOM'd a 16 GB machine from a 12-byte file

Status Fixed / completed
Reported on v2.1.220
Maintainer reply None cached
Activity 1 comment · opened Aug 1, 2026 · closed Aug 20, 2026

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

  1. The substitution is invisible. A user types grep and gets a different program with a materially different memory profile on the same input. Nothing in the session indicates the swap happened.
  2. 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 when grep -C is 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.
  3. 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.
  4. 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 -P by 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_DATA so 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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗