Skip to content

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

Description

@trof3039

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

Activity

  1. bobleer commented on Aug 6, 2026

    @bobleer

    We hit the same class of leak in BitFun. The boring fix had one non-obvious part: after setsid + killpg, the session still cannot finish just because the shell exited; stdout/stderr may still be held open by descendants. Our pipe runner waits for the child and both readers, while interrupt always escalates from SIGINT to SIGKILL for the PGID: implementation, process-group setup/control, and the regression test.

    One easy trap if this gets wired into the timeout path: after the first control message takes and drops the sender, disable that recv branch. With a biased select, a closed receiver stays ready forever and starves reader completion. That gave us a different immortal session on the first pass. #76056 looks like the closest duplicate for the same ugrep/long-line trigger; #68647 is the same ownership gap under parallel subagents.

  2. github-actions commented on Oct 5, 2026

    @github-actions

    Closing for now — inactive for too long. Please open a new issue if this is still relevant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions