Bash/Monitor tool descriptions teach unbounded wait loops; foreground timeout does not kill the process group (leaked orphan shells, 41% CPU for 2d9h)

Status Open
Maintainer reply None cached
Activity 0 comments · opened Aug 15, 2026

Two related defects in Claude Code's Bash/Monitor tooling combine to leak long-running orphan processes on the user's machine. Observed on Claude Code on macOS (darwin, zsh login shell, commands run via a /bin/zsh -c wrapper).

Defect 1 — the built-in tool descriptions teach an unbounded wait loop

Both the Bash tool's run_in_background/Monitor guidance and the Monitor tool description itself suggest polling in the form:

until grep -q "pattern" file; do sleep 0.5; done

with no deadline attached. Sessions copy the suggested shape verbatim. Census over 1,520 local session transcripts: 4,109 Bash/Monitor tool calls contain an until/while loop; 1,715 of those are wait loops (a sleep in the body); 1,599 of the wait loops — 93.2% — carry no deadline of any kind (no timeout, no date +%s cutoff, no $SECONDS, no counter). A replay of a three-shape lexical detector over 123,772 historical Bash/Monitor commands found 1,501 unbounded wait loops, 819 self-matching pgrep -f checks (the wrapper shell's own command line contains the pattern, so until ! pgrep -f X can never terminate), and 425 &-detached children with no wait.

Suggested fix: the tool descriptions should model a bounded poll (a timeout wrapper or a deadline token) instead of the open-ended form, and mention the pgrep -f self-match trap ([p]attern defusal) since the polling command runs inside a wrapper whose command line contains the pattern.

Defect 2 — a foreground Bash timeout stops waiting but does not kill the process group

When a foreground Bash tool call exceeds its timeout parameter, the harness stops waiting and returns control to the model — but the wrapper shell and its process group keep running, invisibly, with no further supervision.

Evidence from this machine:

  • A foreground Bash call issued with timeout: 600000 (10 minutes) was observed still running at 1:22:12 elapsed, its ppid still the live claude process — the harness had long since reported the timeout to the model, but never signalled the wrapper's process group.
  • Four leaked /bin/zsh wrapper shells were found running with no corresponding session activity; one had been spinning at 41% CPU for 2 days 9 hours (an until ! pgrep -f … self-match loop, defect 1's shape, kept alive by this defect).
  • A trailing & inside a command detaches a child that survives the harness timeout entirely and reparents to launchd when the wrapper finally exits — the timeout covers only the direct wrapper, not its descendants.

Controlled reproduction of the descendant hole: a sleep 300 & child survives its parent shell's termination; the same command run under GNU timeout -k 1 3 (which places the command in its own process group and signals the group) exits rc=124 with the detached child reaped. That is the behavior the harness timeout should have.

Suggested fix: on expiry of a Bash tool call's timeout, send SIGTERM to the wrapper's process group (not just the wrapper), followed by SIGKILL after a grace period — i.e., the timeout -k semantics. The same applies to Monitor's timeout_ms. A child that calls setsid() will still escape; killing the group closes the common case.

Local mitigations in place (context, not part of the ask)

  • A bounded-wait wrapper script — wraps a command in GNU timeout -k in its own process group so a deadline reaps & children too.
  • A PreToolUse hook auditing the three lexical shapes (unbounded wait loop, pgrep self-match, & detach without wait).
  • Local rules instructing sessions to add a bound whenever the built-in descriptions suggest the unbounded form.

View original on GitHub ↗