Background `&` jobs in a Bash tool call are orphaned, not reaped: 39 `yes` processes pegged ~7 cores for 8h42m

Status Open
Reported on v2.1.267
Maintainer reply None cached
Activity 0 comments · opened Sep 12, 2026

Summary

Background processes started with & inside a Bash tool call are not reaped when that call ends. They are reparented to PID 1 and keep running after the tool call, after the subagent, and after the session.

In my case a subagent spawned 60 yes > /dev/null processes as a synthetic CPU-load generator to reproduce a timing-dependent test flake. 39 of them were still spinning 8 hours 42 minutes later, each averaging ~25% CPU — roughly 7 of 14 cores consumed continuously, with no indication anywhere in the Claude Code UI that they existed. I only found out because the machine became unusable and the user opened Activity Monitor and saw a wall of yes processes.

This is a "silent, unbounded, machine-wide resource leak" rather than a cosmetic issue: nothing in the session, the task list, or /tasks showed these processes, and they outlived the subagent that created them by many hours.

Evidence

At discovery (~8h42m after spawn):

$ pgrep -x yes | wc -l
39

$ ps -eo pid,ppid,pgid,%cpu,etime,command | awk '$6=="yes"'
40756     1 40734  24.8    08:41:15 yes
42145     1 42141  25.0    08:40:16 yes
42146     1 42141  29.4    08:40:16 yes
...  (39 rows, two process groups: 2 in pgid 40734, 37 in pgid 42141)

Every one had PPID 1 — the spawning shell was long gone, so the children had been reparented rather than killed.

The surviving file descriptors identify the origin exactly:

$ lsof -p 40756
yes  40756  <user>  cwd  DIR  .../.claude/worktrees/agent-<id>          <- the agent's worktree
yes  40756  <user>  txt  REG  /usr/bin/yes
yes  40756  <user>   0r  CHR  /dev/null
yes  40756  <user>   1w  CHR  /dev/null                                  <- `yes > /dev/null`
yes  40756  <user>   2w  REG  /private/tmp/claude-501/<project>/<session-id>/tasks/<task-id>.output

fd 2 pointing at …/<session-id>/tasks/<task-id>.output is what proves these were started by Claude Code's own Bash tool: that is the tool's per-call output file. They also ran at nice 5, which is the niceness the Bash tool applies.

They were not killed by SIGTERM — all 39 survived kill -TERM plus a 2 second wait, and needed kill -9.

Root cause

Recovering the commands from the subagent transcripts under ~/.claude/projects/<project>/<session-id>/subagents/agent-<id>.jsonl, two Bash calls did this:

sysctl -n hw.ncpu; for i in $(seq 1 20); do yes > /dev/null & done; sleep 1; \
  for i in $(seq 1 15); do cargo nextest run -p <crate> --test <target> ... ; done
BIN=target/debug/deps/<test-binary>
for i in $(seq 1 40); do yes > /dev/null & done
sleep 0.5
for i in $(seq 1 200); do $BIN --exact <test-name> --nocapture 2>&1 | ... ; done

The intent was legitimate and it worked: the agent was asked to reproduce a flaky test that only failed under whole-workspace parallel test execution, and generating CPU load with yes > /dev/null is the standard way to force that timing race. It reproduced the flake (2/50 and 2/60) and the root cause was correctly identified and fixed.

What went wrong is the cleanup:

  1. The agent never killed its own background jobs. No trap, no kill %1 %2 …, no pkill -P $$. The load generator was treated as fire-and-forget.
  2. The harness did not clean them up either. When the Bash call finished, the shell exited and the 60 children were reparented to PID 1 instead of being signalled. Nothing later — the subagent completing, four more subagents running in the same worktree, the session continuing for 8+ hours — ever reaped them.

(2) is the part I think is a Claude Code bug. (1) is a model-behaviour problem worth its own guardrail, but even a perfectly behaved agent can be interrupted, hit its turn limit, or be killed between spawning a helper and cleaning it up — and today that leaks CPU on the user's machine permanently.

Why this is worse than it sounds

  • No visibility. These processes appear nowhere: not in the session transcript's visible output, not in /tasks, not in any "background processes" surface. The Bash tool's own backgrounding feature (which is tracked and killable) was not used here — these were plain shell & jobs, and they are invisible.
  • It outlives everything. Subagent exit, session end, and even (I assume, since PPID is 1) quitting Claude Code entirely would not stop them. Only a reboot or a manual kill -9.
  • It compounds. Each agent that tries this adds more. Two calls here produced 60.
  • It is not rate-limited or nice-d enough to be harmless. nice 5 does not stop 39 spinners from taking half the machine.

Reproduction

# in any Claude Code session, as a single Bash tool call:
for i in $(seq 1 5); do yes > /dev/null & done
sleep 1
echo done

Then, after the tool call returns:

pgrep -x yes | wc -l        # => 5
ps -o pid,ppid,command -p $(pgrep -x yes | head -1)   # => PPID 1

The five yes processes survive the call, the turn, and the session.

Suggested fixes

In rough order of how much I think they'd help:

  1. Run each Bash tool call in its own process group and kill that group when the call completes (setsid / setpgid, then kill(-pgid, SIGTERM) followed by SIGKILL after a grace period). This is the standard containment for exactly this problem and would have prevented all of it. Anything the model deliberately wants to outlive the call should have to use the tool's explicit background/run_in_background mechanism, which is already tracked and killable.
  2. Reap at session teardown as a backstop — on session end, kill any process group created by that session's Bash calls that is still alive.
  3. Surface stray children. If a Bash call leaves live children behind, say so in the tool result (note: 5 background processes are still running). Even without automatic killing, that one line would have caught this within seconds instead of 8 hours.
  4. Warn the model. A PreToolUse-style nudge when a command contains a backgrounding loop (… & done) without a trap/kill, or simply documenting in the Bash tool description that & jobs are not cleaned up and that the explicit background mechanism should be used instead.
  5. Consider whether SIGTERM resistance matters here — the survivors needed SIGKILL, so any cleanup path should escalate rather than send one TERM and assume success.

Environment

  • Claude Code 2.1.267
  • macOS (Darwin 25.5.0), arm64, 14 cores
  • Shell: zsh; the calls ran through the normal Bash tool
  • Leak duration observed: 8h42m (spawned 02:15:26 and 02:16:25, found and killed at 10:58 the same day)
  • Processes spawned: 60; still alive at discovery: 39; CPU each: ~25% lifetime average (pure spin)

View original on GitHub ↗