anthropics / anthropics/claude-code

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

Open
#81,462 0 comments 0 reactions 0 assignees View on GitHub
area:tools
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.