Bash/Monitor tool descriptions teach unbounded wait loops; foreground timeout does not kill the process group (leaked orphan shells, 41% CPU for 2d9h)
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, itsppidstill the liveclaudeprocess — the harness had long since reported the timeout to the model, but never signalled the wrapper's process group. - Four leaked
/bin/zshwrapper shells were found running with no corresponding session activity; one had been spinning at 41% CPU for 2 days 9 hours (anuntil ! 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-waitwrapper script — wraps a command in GNUtimeout -kin 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 withoutwait). - Local rules instructing sessions to add a bound whenever the built-in descriptions suggest the unbounded form.