[BUG] tree-kill dependency causes pgrep storm / 100% CPU when reaping Bash subprocess trees on macOS

Resolved 💬 7 comments Opened Apr 23, 2026 by map588 Closed Jul 3, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Environment

  • Claude Code version: 2.1.118 (also reproduced on 2.1.114, 2.1.94, and many earlier — issue persists 8+ months)
  • OS: macOS (darwin, arm64) — Darwin 25.4.0
  • Shell: zsh
  • Install method: native

Summary

Claude Code's bundled tree-kill implementation produces a fork bomb of pgrep processes whenever it tears down a Bash subprocess tree. CPU pegs at 100% and the machine becomes briefly unresponsive. This has been a recurring cause of crashes for me across many versions over the last 8+ months and has never been addressed.

Root cause

The minified Bg6 function in the bundled binary is the standard npm tree-kill recursive walker. On darwin it discovers descendants by spawning pgrep -P <pid> once per node, recursively:

case "darwin":
  Bg6(H, K, O, T => F89("pgrep", ["-P", T]), () => U89(K, _, q));
  break;

function Bg6(H, _, q, K, O) {
  var T = K(H), $ = "";
  T.stdout.on("data", Y => { $ += Y.toString("ascii"); });
  T.on("close", function (z) {
    if (delete q[H], z != 0) {
      if (Object.keys(q).length == 0) O();
      return;
    }
    $.match(/\d+/g).forEach(function (Y) {
      Y = parseInt(Y, 10);
      _[H].push(Y); _[Y] = []; q[Y] = 1;
      Bg6(Y, _, q, K, O);   // recursive fan-out, one pgrep per descendant
    });
  });
}

For every descendant in the victim's process tree, exactly one pgrep is forked. When the victim is a typical interactive Bash with a fat subtree (zsh + MCP stdio servers + caffeinate + npm exec + node + child shells), one teardown can spawn hundreds of pgreps. When several Bash tool invocations time out or are aborted concurrently — common during subagent batches or parallel tool calls — the bursts overlap and saturate the CPU. macOS pgrep is not cheap (~50–100ms cold), so the cost compounds quickly.

Secondary defect

If pgrep exits 0 with empty stdout (no children, race during teardown), $.match(/\d+/g) returns null and .forEach throws an uncaught TypeError mid-walk. This silently aborts cleanup and leaves orphan descendants behind, which then survive until the next walk and inflate the next storm.

This exhausts the user process limit, and closing the shell leaves claude code as an orphan, which I then need to force quit in activity monitor, because in this state I cannot open another shell.

What Should Happen?

Claude should be able to search for its spawned processes without pgrep cascade reaching the user process limit.

Error Messages/Logs

Steps to Reproduce

Reproduction

Easiest to provoke:

  1. Have at least one MCP stdio server configured (any combination — I have tokensave, read-website-fast, memstate).
  2. Run a Bash tool command that spawns nested processes and exceeds the timeout, e.g. bash -c 'for i in $(seq 1 20); do (sleep 60 &); done; sleep 120'.
  3. Press Esc to abort, or let it time out.
  4. Observe with sudo execsnoop | grep pgrep — you will see a burst of pgrep -P <pid> calls. Repeat a few times, especially with parallel Bash, and CPU pegs.

Claude Model

Not sure / Multiple models

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.118

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

iTerm2

Additional Information

Suggested fix

Replace the recursive per-node pgrep walk on darwin with a single ps snapshot, then build the tree in JS. One fork instead of N:

// darwin / linux: one process listing, build the tree in memory
const out = execSync('ps -axo pid=,ppid=').toString();
const childrenByPpid = new Map();
for (const line of out.split('\n')) {
  const [pid, ppid] = line.trim().split(/\s+/).map(Number);
  if (!pid) continue;
  if (!childrenByPpid.has(ppid)) childrenByPpid.set(ppid, []);
  childrenByPpid.get(ppid).push(pid);
}
const descendants = [];
const stack = [rootPid];
while (stack.length) {
  const p = stack.pop();
  descendants.push(p);
  for (const c of childrenByPpid.get(p) || []) stack.push(c);
}
// then signal in leaf-first order

This eliminates the fan-out entirely, removes the recursive Node spawn overhead, and sidesteps the empty-stdout match crash. It is also strictly faster than the upstream tree-kill behavior on Linux as well, so the platform branch can collapse.

If sticking with tree-kill upstream is preferred, please at minimum:

  • Guard $.match(/\d+/g) against null.
  • Cap concurrency of in-flight pgrep invocations (a small semaphore).
  • Debounce/coalesce kills when multiple Bash tools terminate within the same tick.

This has been an intermittent but reliable cause of system-wide pauses for me across many versions. The recursive shell-spawning pattern is an obvious source of runaway behavior and would normally have been flagged in review. Asking that it finally get prioritized.

View original on GitHub ↗

This issue has 7 comments on GitHub. Read the full discussion on GitHub ↗