[BUG] macOS: foreground Bash completion leaves background child orphaned under PID 1 (2.1.220)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 0 comments · opened Jul 26, 2026

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

  1. Start Claude Code 2.1.220 on macOS.
  2. Run a foreground Bash tool call with a unique marker:

``sh
sh -c 'exec -a claude-orphan-repro sleep 600' & echo done
``

  1. Wait for the Bash tool to return done.
  2. 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:

  1. Spawn command root as process-group leader (setpgid(child, child); in Node, detached: true without unref() provides a new POSIX process group/session).
  2. Foreground call: when root shell exits, send SIGTERM to -pgid, wait a short grace period, then SIGKILL remaining members.
  3. Background call: keep PGID attached to task record; perform same group teardown on task completion, TaskStop, timeout, cancellation, and session exit.
  4. Treat ESRCH as already clean. Do not enumerate descendant PIDs; PID-list traversal races with forks.
  5. 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.

View original on GitHub ↗