[BUG] tree-kill dependency causes pgrep storm / 100% CPU when reaping Bash subprocess trees on macOS
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:
- Have at least one MCP stdio server configured (any combination — I have tokensave, read-website-fast, memstate).
- 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'.
- Press Esc to abort, or let it time out.
- 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.
This issue has 7 comments on GitHub. Read the full discussion on GitHub ↗