Skip to content

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

Closed as not planned

Description

@map588

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:

  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 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

  1. boshu2 commented on May 2, 2026

    @boshu2

    Confirming this on macOS Sequoia / M5 / 32 GB / claude 2.1.126. Your Bg6 analysis is correct and matches my forensic capture exactly.

    I filed #55609 earlier today with what I now believe is downstream of the same tree-kill fork-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:

    "The capture monitor itself hit fork: retry: Resource temporarily unavailable repeatedly while trying to spawn sample/vm_stat — kernel was system-wide out of process slots, not just claude stuck"

    That's RLIMIT_NPROC saturating because of the recursive pgrep fan-out you describe. From a freeze-capture monitor I had running this morning (08:32–12:05 PDT, ~3.5 hours):

    • 21 distinct freeze events across 5 separate claude PIDs (28428, 42495, 47974, 55383, 99538)
    • System 1-min load average peaked at 18.99 during freezes, baseline 3.37
    • Stack trace from PID 77747: 2,561 main-thread samples, all in same call path + 0x6569a0 → 0xe37c6c → 0x849910 → ... — likely the JS callsite of the Bg6 recursive walker

    The 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 Bg6 then recursively pgreps 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 pgrep with a single ps -eo pid,ppid,pgid parse and walk the tree client-side. One process spawned per teardown instead of N. The tree-kill upstream issue tracker has had this proposed for years. macOS pgrep is 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.

  2. boshu2 commented on May 2, 2026

    @boshu2

    Quick correction on my comment above — on closer review of my sample profiles, the parent claude main thread was actually in kevent64 (event-loop blocking call) across all 25 captured-freeze samples, not in a syscall loop as I implied. Zero posix_spawn / fork / socketpair samples in any of them.

    Which actually CONFIRMS your Bg6 hypothesis more cleanly than my original read did: the parent isn't doing the work — it's sitting in kevent64 waiting for events while the recursive pgrep children storm the kernel. The fork-failure (Resource temporarily unavailable) the monitor caught is RLIMIT_NPROC saturating 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.

  3. boshu2 commented on May 2, 2026

    @boshu2

    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 Bg6 hypothesis predicted. My earlier sample captures 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 seconds
    • CPU Time: 10.547s — main thread alone is 9.579s (1000/1000 samples)
    • 30 threads; manual kill -9 to recover

    Main-thread hot path: __posix_spawn → __socketpair → setsockopt → __close_nocancel → __fcntl in a tight loop. No kevent64.

    Kernel turnstile waits explicitly name the children:

    blocked by turnstile waiting for pgrep [45485]
    blocked by turnstile waiting for pgrep [45486]
    blocked by turnstile waiting for spindump [45475]
    

    8 short-lived 2.1.126 child 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 — every setsockopt on 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 backgrounded Bash tool). Vanilla {} settings does not trigger.

    The single-ps rewrite you proposed would land this. Forensic file (7.5 MB / 75,966 lines) available on request.

  4. map588 commented on May 2, 2026

    @map588
    Author

    @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.

  5. danmactough commented on May 13, 2026

    @danmactough

    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 Stop hook 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:

    #!/usr/bin/env bash
    echo '{}'
    exit 0

    Trigger:

    claude --plugin-dir ./plugin -p 'hi'
    

    Claude crashes before it can answer:

    Uncaught exception (loop): Error: EAGAIN: resource temporarily unavailable, posix_spawn '/opt/homebrew/bin/pgrep' at node:child_process:667:35
    Uncaught exception (loop): Error: EAGAIN: resource temporarily unavailable, posix_spawn '/opt/homebrew/bin/pgrep' at node:child_process:667:35
    ... (repeats) ...
    Uncaught exception loop detected (10 in 5000ms) — forcing shutdown
    

    A/B numbers from a controlled run

    Same plugin in both rows, only the manifest's async flag varied. eslogger capture and claude stderr counts:

    Stop async claude-binary pgrep calls pgrep -P walks Distinct PIDs scanned EAGAIN errors in stderr Exception loop claude exit
    false 0 0 0 0 0 0
    true 2424 2424 764 4 1 137

    distinct PIDs scanned ≈ the host's entire process table. Same Bg6 recursive pgrep -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

    • macOS 26.4.1 / Darwin 25.4.0 / arm64
    • Claude Code CLI 2.1.140
    • Reproduces with all other custom plugins disabled (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 the async flag, runs claude --plugin-dir ./plugin -p 'hi' under sudo eslogger exec, and writes a Markdown comparison table.
    • capture-cli.sh — single-config one-shot capture.

    Both harnesses use a perl ... setsid(); exec wrapper 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/) survives kill -KILL of 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 recursive pgrep -P with a single ps -eo pid,ppid,pgid parse 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.

  6. pmarreck commented on May 30, 2026

    @pmarreck

    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

    • Claude Code 2.1.158
    • macOS 26.5 (Tahoe, build 25F5042g), Apple Silicon (M4 Max)
    • bash interactive shell; multiple concurrent Claude Code sessions in tmux, each operating in a separate project directory
    • Kernel ceilings are high, which rules out a low-limit misconfig: kern.maxproc=16000, kern.maxprocperuid=10666, kern.maxfiles=10485760

    Symptom progression (matches #52253's mechanism, worse tail)

    1. Over a long multi-session run, process count climbs steadily.
    2. Eventually every fork()/posix_spawn in the whole user session begins blocking with EAGAIN — not just Claude Code. The terminal can't finish rendering its login art; git, cat, ls hang or get SIGTERM'd.
    3. exec <cmd> (which replaces the shell image without forking) is the only thing that still runs.
    4. shutdown could 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

    • The failure builds up specifically during heavy Bash-tool use with backgrounded/timed-out commands — exactly the teardown path [BUG] tree-kill dependency causes pgrep storm / 100% CPU when reaping Bash subprocess trees on macOS #52253 describes (one pgrep -P per descendant, fan-out on concurrent timeouts).
    • The recursive Bg6 walk + the empty-stdout $.match(/\d+/g) → null.forEach TypeError (which aborts cleanup and orphans the subtree) together produce both the pgrep storm and an accumulating orphan-process leak — the orphans are what eventually exhaust maxprocperuid.
    • The proposed fix in this issue (single 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,comm every 15s) across a ~75-minute session. Per-uid process count climbed monotonically 419 → 494 with no plateau while Claude Code was active; lingering bash interpreters 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-wide maxproc=16000 and 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 proctools shadow-pgrep angle from the related issues is NOT my cause — I'm on nix-darwin, system pgrep only, no Homebrew. So this is the genuine tree-kill fan-out path, independent of the proctools red herring.)

  7. github-actions commented on Jul 3, 2026

    @github-actions

    Closing for now — inactive for too long. Please open a new issue if this is still relevant.

  8. github-actions commented on Sep 4, 2026

    @github-actions

    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.

  9. locked as resolved and limited conversation to collaborators on Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:bashbugSomething isn't workinghas reproHas detailed reproduction stepsperf:cpuplatform:macosIssue specifically occurs on macOSstaleIssue is inactive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions