Bash tool timeout does not kill the child: an orphaned grep reached 20 GB RSS and outlived both its timeout and its subagent
Summary
When a Bash tool call exceeds its timeout, the child process is not killed. It keeps running, unattached, for as long as it wants. It also survives the death of the subagent that spawned it. Nothing in the session ever reaps it.
In this session that produced a single ugrep process holding 20.24 GB of physical memory and driving the machine into swap exhaustion. The user noticed only because the whole Mac became unusable, and asked me why "my process" was eating 20 GB. It was not the Claude Code process (that was 637 MB); it was an orphan the harness left behind ~15 minutes earlier.
Evidence
Measured with ps and footprint while the machine was degraded:
$ footprint -p 4396
phys_footprint: 20 GB
phys_footprint_peak: 20 GB
$ ps -o pid,ppid,rss,etime,command -p 4396
PID PPID RSS ELAPSED COMMAND
4396 4395 58928 15:42 ugrep -G --ignore-files --hidden -I ... -o -i -E .{0,120}(rent|internal business|...).{0,300}
ELAPSED 15:42 against a default Bash timeout of 120 s. The tool call had returned to the model twelve minutes earlier.
A second orphan from the same batch held 4.63 GB (pid 11828, ELAPSED 12:05).
System state at that moment:
$ sysctl vm.swapusage
vm.swapusage: total = 12288.00M used = 11928.38M free = 359.62M
System-wide memory free percentage: 25%
The parent chain shows the orphan was still hanging off the Claude Code process, so this is not a detached/backgrounded job the user created:
4396 -> 4395 (/bin/zsh -c ... shell-snapshots/snapshot-zsh-*.sh ... )
4395 -> 4389 (/bin/zsh -c ... same wrapper ... )
4389 -> 58700 (claude)
Both orphans were spawned by background subagents that had already completed. The completion notification for one of them had already been delivered to the main loop while its 20 GB child was still running.
Reproduction
Any command whose pipeline blows up past the timeout. The one that triggered it here:
curl -sL --max-time 25 "https://modal.com/legal/terms" \
| sed 's/<[^>]*>/ /g' \
| tr -s ' \n' ' \n' \
| grep -o -i -E '.{0,120}(rent|internal business|Customer shall not|shall not \(a\)).{0,300}' \
| head -8
Note that this command is defensively written and still leaks: curl is capped with --max-time 25, and the pipeline ends in head -8. It leaks anyway, because grep never emits a line, so it never receives SIGPIPE from head, and nothing else ever signals it.
On systems where grep resolves to ugrep (common with modern dotfiles), a bounded-repetition pattern like .{0,120}(…).{0,300} applied to HTML that sed has collapsed into one very long line will allocate without bound. That part is a ugrep characteristic, not a Claude Code bug. The Claude Code bug is that the runaway is never killed.
A minimal synthetic repro that does not depend on ugrep:
# with default 120s timeout; check `ps` after the tool call returns
python3 -c "a=[]
while True: a.append(' '*10_000_000)"
Expected behaviour
- On timeout, kill the entire process group, not just the shell wrapper.
setsidthe command, thenkill(-pgid, SIGTERM)followed bySIGKILLafter a short grace period. - When a subagent finishes, reap any process group it still owns.
- On session exit, reap everything spawned by the session.
Actual behaviour
Timeout returns a message to the model and abandons the child. The child then outlives its subagent, the notification, and (presumably) the session.
Why this deserves high priority
- It is silent. The model is told "command timed out" and moves on, with no signal that a process is still burning resources. I only diagnosed it because the user reported his machine was dying.
- The blast radius is the whole machine, not the session: swap exhaustion degrades every other application.
- Agents make this much more likely than interactive use. A background subagent sweeping many URLs issues dozens of pipelines; one pathological input is enough, and the agent that could have cleaned up has already exited.
- There is no user-visible surface for it. Nothing in the UI lists processes the session left running, so a user has to reach for Activity Monitor and then correctly attribute a process named after a version string (
ugrepshows up as2.1.223) to Claude Code.
Suggested secondary improvement
Even before timeout-kill lands, a cheap mitigation would help: apply an RLIMIT_AS/ulimit -v ceiling to Bash tool children, so a runaway allocation dies on its own instead of taking the host down.
Environment
- Claude Code CLI, macOS (Darwin 24.4.0), Apple Silicon, 8 GB physical RAM
- shell: zsh,
grepaliased/resolved tougrep2.1.223 via the user's environment - default Bash tool timeout (120 000 ms)
- three background subagents of type general-purpose running concurrently
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗