anthropics / anthropics/claude-code
[BUG] macOS: foreground Bash completion leaves background child orphaned under PID 1 (2.1.220)
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
## 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:
```sh
until false; do :; done 2>/dev/null & sleep 1; echo placeholder
```
The tool returned `placeholder` successfully. The background subshell remained alive:
```text
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
```
3. Wait for the Bash tool to return `done`.
4. 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:
```text
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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start at the Bash tool invocation and its foreground/background task lifecycle, including TaskStop, timeout, cancellation, and session exit. Reproduce the nested-child cases described in the issue, then verify that foreground completion and every terminal task state leave no owned process-group members and that PGID information is recorded in telemetry.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bash, macos, zsh
- Domain
- cli, devtools, operating-systems
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100