Bash tool timeout does not kill the child: an orphaned grep reached 20 GB RSS and outlived both its timeout and its subagent

Status Open
Reported on v2.1.223
Maintainer reply None cached
Activity 1 comment · opened Aug 6, 2026

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

  1. On timeout, kill the entire process group, not just the shell wrapper. setsid the command, then kill(-pgid, SIGTERM) followed by SIGKILL after a short grace period.
  2. When a subagent finishes, reap any process group it still owns.
  3. 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 (ugrep shows up as 2.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, grep aliased/resolved to ugrep 2.1.223 via the user's environment
  • default Bash tool timeout (120 000 ms)
  • three background subagents of type general-purpose running concurrently

View original on GitHub ↗

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