Repository navigation
Description
Preflight Checklist
- I have searched existing issues and this hasn't been reported yet
- This is a single bug report (please file separate reports for different bugs)
- 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 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 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.
Activity
boshu2 commentedon May 2, 2026
Confirming this on macOS Sequoia / M5 / 32 GB / claude 2.1.126. Your
Bg6analysis is correct and matches my forensic capture exactly.I filed #55609 earlier today with what I now believe is downstream of the same
tree-killfork-bomb you've documented here. The bot suggested this issue as a duplicate. After reading your root-cause analysis, I agree.The kernel-level signature you predicted — process-limit exhaustion — is exactly what I caught:
That's
RLIMIT_NPROCsaturating because of the recursivepgrepfan-out you describe. From a freeze-capture monitor I had running this morning (08:32–12:05 PDT, ~3.5 hours):+ 0x6569a0 → 0xe37c6c → 0x849910 → ...— likely the JS callsite of theBg6recursive walkerThe hook angle — for any reader landing here — looks like an amplifier, not a separate bug. Hooks (SessionStart / PreToolUse / etc.) spawn shell subprocesses that grow the descendant tree that
Bg6then recursivelypgreps on teardown. Vanilla installs without hooks are smaller trees → much less likely to hit the storm. That matches the regression timing in #22172 (v2.1.23+ broke parallel-instances-with-hooks) and the V8-loop disassembly in #18532.Smallest-possible fix that would land the symptom: replace the recursive
pgrepwith a singleps -eo pid,ppid,pgidparse and walk the tree client-side. One process spawned per teardown instead of N. Thetree-killupstream issue tracker has had this proposed for years. macOSpgrepis genuinely expensive enough (~50–100 ms cold per @map588) that this is worth doing even without the storm-mode pathology.Forensic dir + sample profiles ready to share. Disclosure: I build AgentOps on top of Claude Code; filing as a daily user.
boshu2 commentedon May 2, 2026
Quick correction on my comment above — on closer review of my sample profiles, the parent
claudemain thread was actually inkevent64(event-loop blocking call) across all 25 captured-freeze samples, not in a syscall loop as I implied. Zeroposix_spawn/fork/socketpairsamples in any of them.Which actually CONFIRMS your
Bg6hypothesis more cleanly than my original read did: the parent isn't doing the work — it's sitting inkevent64waiting for events while the recursivepgrepchildren storm the kernel. The fork-failure (Resource temporarily unavailable) the monitor caught isRLIMIT_NPROCsaturating from the children, exactly as you predicted, with the parent untouched.Also: corrected count — 22 freezes between 08:32:51 and 12:07:10, not 21 / 12:05. Both under-counts on my part.
The cross-link to @smconner's V8 JIT loop in #18532 may be a different layer of the same bug (V8 JIT response to socket-handle churn from the storm), not a single-loop signature. I'll be more careful about that distinction.
boshu2 commentedon May 2, 2026
Correction to my second comment above — fresh 10s system spindump on the same PID 28428 contradicts the kevent64 read I posted. The parent IS the spawner, exactly as your
Bg6hypothesis predicted. My earliersamplecaptures were of the post-storm freeze state (parent blocked in event loop after child spawns failed); this spindump caught the storm itself.Spindump: 2026-05-02, 10s, 1000 stackshots @ 10ms (macOS 26.4.1 / Mac17,2 / 32 GB / claude 2.1.126 / Ghostty)
PID 28428:
Footprint: 1385.58 MB -> 2962.72 MB (+1577.14 MB)in 10 secondsCPU Time: 10.547s— main thread alone is 9.579s (1000/1000 samples)kill -9to recoverMain-thread hot path:
__posix_spawn → __socketpair → setsockopt → __close_nocancel → __fcntlin a tight loop. Nokevent64.Kernel turnstile waits explicitly name the children:
8 short-lived
2.1.126child PIDs captured in the same window (45494, 45498, 45508, 45512, 45540, 45552, 45562, 45603 — all 32–48 KB stubs), confirming recursive fan-out, not a single fork.Sandbox amplifier (per spawn):
hook_socket_check_setsockopt → sb_evaluate_internal → eval_op— everysetsockopton the spawn-pipe pays seatbelt-policy eval. Responsible app is Ghostty, so children inherit the profile.Reproduces reliably with the 4-step repro in #55609 (any hook in
settings.json+ interrupt a backgroundedBashtool). Vanilla{}settings does not trigger.The single-
psrewrite you proposed would land this. Forensic file (7.5 MB / 75,966 lines) available on request.map588 commentedon May 2, 2026
@claude It is ridiculous this issue is independently verified, and is a potential root cause or compounding factor to other submitted issues, and yet its been unresolved for months. Two proposed solutions, one by myself, the other proposed by @boshu2, exist. Please investigate and fix.
danmactough commentedon May 13, 2026
Confirming this on a strictly simpler trigger than what's in the OP or in #55609. No MCP servers, no backgrounded bash tool, no interrupt/abort, no user action — fires on session start as soon as the plugin manifest is loaded. The single condition is one plugin declaring a
Stophook with"async": true.Minimal repro is a 3-file Claude Code plugin. Hook script body is a no-op (
echo '{}'; exit 0).plugin/.claude-plugin/plugin.json:{ "name": "stop-async-bomb", "version": "0.0.1" }plugin/hooks/hooks.json:{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/stop.sh", "async": true, "timeout": 10 } ] } ] } }plugin/hooks/stop.sh:Trigger:
Claude crashes before it can answer:
A/B numbers from a controlled run
Same plugin in both rows, only the manifest's
asyncflag varied. eslogger capture and claude stderr counts:asyncpgrepcallspgrep -PwalksEAGAINerrors in stderrfalsetruedistinct PIDs scanned≈ the host's entire process table. SameBg6recursivepgrep -P 0,-P 1,-P <every pid>pattern documented in this issue's OP, fired at session start instead of subprocess teardown. Confirms the walker is invoked once per registered async hook, and that the manifest entry alone — independent of whether the hook ever runs — is enough to fire it.A/B also rules out the secondary
pgrep-returns-empty case from the OP as a precondition: the bomb fires on the very first session of a clean process tree, no orphaned descendants from a prior walk.Tested environment
claude plugins disable …). Stock plugins without hooks (claude-md-management,skill-creator) do not affect the test.Public repo with full reproducer
https://github.com/danmactough/stop-async-bomb
Contains:
plugin/— the 3-file plugin above.compare-scenarios.sh— automated A/B harness that flips theasyncflag, runsclaude --plugin-dir ./plugin -p 'hi'undersudo eslogger exec, and writes a Markdown comparison table.capture-cli.sh— single-config one-shot capture.Both harnesses use a
perl ... setsid(); execwrapper around claude so the runaway can be killed by the script (without it, the version-named subprocess — e.g.2.1.140(see #49852), because the CLI installer stores each version as a separately-named binary in~/.local/share/claude/versions/) surviveskill -KILLof the parent and has to be cleared in Activity Monitor. (At least, that's what Claude thought would work. I've still needed to kill the runaway in Activity Monitor.)Suggested fix scope
The fix proposed earlier in this thread — replacing
tree-kill's recursivepgrep -Pwith a singleps -eo pid,ppid,pgidparse and a client-side tree walk — would close this trigger path along with the OP's.Happy to add per-syscall NDJSON or claude.err artifacts to the repo if useful.
pmarreck commentedon May 30, 2026
Reproduced and escalated on a newer build — this is not just a CPU spike, it can wedge the entire machine to the point where
shutdown(8)itself can't fork and a hard power-cycle is the only recovery.Environment
kern.maxproc=16000,kern.maxprocperuid=10666,kern.maxfiles=10485760Symptom progression (matches #52253's mechanism, worse tail)
fork()/posix_spawnin the whole user session begins blocking with EAGAIN — not just Claude Code. The terminal can't finish rendering its login art;git,cat,lshang or get SIGTERM'd.exec <cmd>(which replaces the shell image without forking) is the only thing that still runs.shutdowncould not complete (it needs to fork) — required a hard power-cycle. This happened twice.Why I'm confident tree-kill / pgrep reaping is a contributor here
pgrep -Pper descendant, fan-out on concurrent timeouts).Bg6walk + the empty-stdout$.match(/\d+/g)→null.forEachTypeError (which aborts cleanup and orphans the subtree) together produce both the pgrep storm and an accumulating orphan-process leak — the orphans are what eventually exhaustmaxprocperuid.ps -axo pid=,ppid=snapshot, build the tree in memory, no per-node fork) would eliminate both the storm and the orphan leak. Strongly seconding it.Concrete corroboration of the orphan-leak tail: I ran a lightweight sampler (
/bin/ps -axo uid,commevery 15s) across a ~75-minute session. Per-uid process count climbed monotonically 419 → 494 with no plateau while Claude Code was active; lingeringbashinterpreters from completed tool-calls were a visible fraction. At that slope against a fork that's already intermittently EAGAIN-ing, the wall is reached well before the nominal 10666 cap because the system-widemaxproc=16000and FD pool are shared with everything else.Ask: please un-stale and prioritize — the documented worst case is now "unrecoverable without a hard reboot," which is data-loss-risky, not just annoying. The single-
ps-snapshot fix already sketched in this thread looks correct and low-risk.(Note: I also confirmed the classic
proctoolsshadow-pgrep angle from the related issues is NOT my cause — I'm on nix-darwin, systempgreponly, no Homebrew. So this is the genuine tree-kill fan-out path, independent of the proctools red herring.)github-actions commentedon Jul 3, 2026
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
github-actions commentedon Sep 4, 2026
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.