[BUG] macOS: foreground Bash completion leaves background child orphaned under PID 1 (2.1.220)
Summary
On macOS, a foreground Bash tool call can return successfully while a shell-level background child survives indefinitely. Claude Code loses ownership of that child; launchd adopts it as PPID 1. In a real incident, the escaped child consumed one full CPU core for 10 days.
This is reproducible on the current release, Claude Code 2.1.220, on macOS Darwin 25.4.0 arm64.
Actual incident
The Bash tool executed:
until false; do :; done 2>/dev/null & sleep 1; echo placeholder
The tool returned placeholder successfully. The background subshell remained alive:
PID PPID %CPU ELAPSED COMMAND
63518 1 94.8 10-19:55:08 /bin/zsh -c ... eval 'until false; do :; done 2>/dev/null & sleep 1; echo placeholder' ...
The process had no live Claude Code parent and no task handle. It required external discovery and termination.
The command originated after Claude Code's long-sleep blocker rejected sleep 40; echo done and suggested an until monitor. Model behavior created the bad loop, but runner containment should prevent any unregistered descendant from outliving a completed foreground tool call.
Safe reproduction
- Start Claude Code 2.1.220 on macOS.
- Run a foreground Bash tool call with a unique marker:
``sh``
sh -c 'exec -a claude-orphan-repro sleep 600' & echo done
- Wait for the Bash tool to return
done. - From another terminal, inspect:
``sh``
pgrep -af claude-orphan-repro
Expected: no matching process after foreground tool completion.
Actual: child remains alive, eventually adopted by PID 1.
Root cause
The runner tracks the root zsh wrapper, not the full POSIX process group. When the wrapper exits normally, descendants created with shell & remain alive. No per-command teardown runs on successful foreground completion.
This is the macOS/POSIX equivalent of the missing per-command containment documented in #62659. Related macOS wrapper leakage was reported in #61060, but that issue was closed as not planned and its reproduction focused on pipe truncation/polling wrappers rather than deterministic shell-child escape after a successful tool result.
Proposed POSIX fix
Give every Bash tool invocation a dedicated process group and retain ownership until the tool/task lifecycle ends:
- Spawn command root as process-group leader (
setpgid(child, child); in Node,detached: truewithoutunref()provides a new POSIX process group/session). - Foreground call: when root shell exits, send
SIGTERMto-pgid, wait a short grace period, thenSIGKILLremaining members. - Background call: keep PGID attached to task record; perform same group teardown on task completion, TaskStop, timeout, cancellation, and session exit.
- Treat
ESRCHas already clean. Do not enumerate descendant PIDs; PID-list traversal races with forks. - Record PGID in debug/task telemetry so orphan incidents remain attributable.
Required invariant:
foreground tool result emitted => command process group empty
background task terminal state => command process group empty
session exit => all owned command groups empty
Regression tests
- Foreground command
sleep 600 & echo done: tool returns, marker PID absent. - Foreground command with several nested children: entire PGID absent after root exit.
- Timeout and user cancel: TERM grace, then KILL; no descendants.
run_in_background: true: child remains while task is live, disappears on TaskStop/completion.- Root exits before teardown begins: negative-PGID signal still reaps surviving group members.
Impact
Escaped processes can burn CPU, retain file locks, write unbounded task output, or keep network listeners alive for days. Documentation/model prompting cannot guarantee containment; this requires runner-level ownership.