Orphaned wrapper shells accumulate and pin CPU (~1400%) after Bash calls containing shell loops with command substitution

Status Open
Maintainer reply None cached
Activity 1 comment · opened Jul 25, 2026

Summary

During a long-running session, orphaned wrapper shells accumulate and pin the CPU. They are never reaped. Twice in one session they reached 110 processes at ~1413% CPU and 80 processes at ~1255% CPU on an otherwise idle machine.

Every orphan is executing the same thing — the harness's own cwd-tracking postamble, not my command:

/bin/zsh -c source ~/.claude/shell-snapshots/snapshot-zsh-<id>.sh 2>/dev/null || true && ... && pwd -P >| /tmp/claude-<id>-cwd

Key observations:

  • All 80 orphans in the second incident were byte-identical and had the same elapsed time to the second (03:28), so they were spawned in one instant from a single Bash tool call.
  • All had PPID 1 — their parent shell was gone and they had been reparented to init.
  • They did not exit. They sat at ~15% CPU each, apparently spinning, indefinitely.
  • Nothing reaps them, so they accumulate over the life of a session. SIGTERM cleared them; no SIGKILL was needed in the first incident.

Reproduction

Any Bash tool call whose payload is a shell loop containing command substitution seems to do it. The trigger in both incidents was this shape:

for h in example.com example.org example.net; do
  out=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "https://$h/" 2>&1)
  printf '%s %s\n' "$h" "$out"
done

A prompt that reliably produces that shape:

Probe these 20 hostnames over HTTPS and report each one's status code and final redirect URL.

The model naturally writes a shell for/while loop with $(...) per iteration. The orphans appear once that call ends — especially if it is slow enough to be interrupted, hit a timeout, or outlive the subagent that issued it.

Hypothesised mechanism

Offered as a hypothesis; I have the observations above but did not instrument the shell.

The wrapper appears to be roughly zsh -c 'source <snapshot> && <payload>; pwd -P >| /tmp/claude-<id>-cwd'. Each $(...) inside the payload forks a subshell. A forked zsh child holds a copy of the parent's remaining command list — including the trailing pwd -P postamble.

If the parent shell is killed while those children are between fork and exec (call timeout, interrupt, or subagent teardown), the children survive, fall through to the inherited postamble, get reparented to init, and never terminate. That would explain all four observations: identical command, identical age, PPID 1, and a count far higher than the number of loop iterations.

Impact

  • Silent CPU exhaustion on the user's machine. The user noticed only because the fans spun up; nothing in the CLI surfaces it.
  • Cumulative across a session. Two separate incidents in one session, each needing manual cleanup.
  • Cost is misattributed. The CPU is burned by the harness postamble, not by any user or model command, so it is invisible to anyone reading the transcript.

Workaround

Avoid shell loops entirely in Bash calls. Put the iteration inside a single interpreter process:

python3 - <<'EOF'
# one process, no per-iteration forks, nothing to orphan
EOF

After switching every loop to that pattern, the problem stopped recurring in the same session.

Manual cleanup, which is safe because these are orphans whose parent is already gone:

ps -Ao pid=,ppid=,args= | awk '$2==1 && /claude\/shell-snapshots/ {print $1}' | xargs -r kill -TERM

Suggested fixes

  1. Make the postamble not survive a fork — write the cwd from the wrapper's exit path rather than as a trailing command in the same -c string, so a forked child cannot inherit it.
  2. Put each Bash call's processes in their own process group and signal the group on teardown, so children die with the parent.
  3. Failing both, reap orphaned snapshot shells at session start or on a timer.

Environment

  • macOS (Darwin 25.x), arm64
  • zsh as the login shell
  • Claude Code CLI, long-lived session with several concurrent subagents also issuing Bash calls

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗