[BUG] Hung ps -A -o pid= -o ppid= child processes accumulate until the per-user process limit is exhausted (macOS)

Status Open
Reported on v2.1.218
Maintainer reply None cached
Activity 3 comments · opened Jul 23, 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?

Each claude process spawns its internal process-tree probe ps -A -o pid= -o ppid= every ~2–3 s. The children never exit (state S+, empty wchan) and accumulate — we observed 2,210 hung ps processes across 5 sessions (~50 per session per 2 minutes). Once kern.maxprocperuid (2666) is reached, every fork on the machine fails with EAGAIN: git pushes, Claude Code's own Bash tool, background tasks and monitors.

What Should Happen?

The internal process-tree probe (ps -A -o pid= -o ppid=) should complete and be reaped within milliseconds, regardless of how many processes exist on the system. Spawned ps children should never accumulate: Claude Code should drain the child's stdout concurrently (or kill the child on timeout instead of abandoning it), so a large process table cannot make ps block forever on a full pipe. Long-running sessions must not exhaust kern.maxprocperuid — once that limit is hit, every fork on the machine fails (git, Claude Code's own Bash tool, background tasks), which is effectively a self-inflicted denial of service on the host.

Error Messages/Logs

Steps to Reproduce

not sure

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.218

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Warp

Additional Information

Verification: ps -p <hung-pids> -o pid,ppid,wchan,args shows ps -A -o pid= -o ppid= with the claude binary as direct parent. Not caused by statusline/hooks (parent would be their shell).

Hypothesis: the spawned ps's stdout pipe isn't drained (or the spawn is abandoned on timeout without kill). When total system process count grows large enough that the output exceeds the pipe buffer, ps blocks on write forever — and each hung instance grows the process table, making the next one hang too (self-amplifying).

View original on GitHub ↗

3 Comments

natedame · 1 month ago

Independent confirmation of this on macOS, plus a source-level root cause and one consequence I don't think has been noted yet: the timeout also silently breaks the kill itself.

Where it is

ps -A -o pid= -o ppid= appears exactly once in the v2.1.250 binary, inside the killProcessTree helper. Recovered logic (minified names preserved):

async function w(e){
  let o;
  try{ o = await Promise.race([ g(), new Promise(u => { let i = setTimeout(m => m(""), k, u); if (typeof i === "object") i.unref() }) ]) }
  catch(u){ return f("enum_spawn", u), new Set }
  let t = new Map;
  for (let u of o.split("\n")) { let i = u.match(/^\s*(\d+)\s+(\d+)\s*$/); if(!i) continue; ... }
  // BFS descendants of e
}
function g(){
  return new Promise((e,o) => {
    let t = p("ps", ["-A","-o","pid=","-o","ppid="], {cwd:"/", stdio:["ignore","pipe","ignore"], windowsHide:!0});
    let r = ""; t.stdout?.on("data", l => r += l), t.once("error", o), t.once("close", () => e(r))
  })
}
async function h(e,o){
  let t = await w(e);
  try{ process.kill(-e, o) } catch(r){ ... }
  for (let r of t) try{ process.kill(r, o) } catch{}
}

k is a hardcoded 500.

Two distinct consequences, not one

1. The abandoned child (the leak in this issue). The race resolves with "" after 500ms; it does not reject and it does not kill. w() holds no reference to the ChildProcess created inside g()'s closure, so there is no handle available to kill it. The ps runs on unwatched.

2. The kill silently becomes partial — I think this is the more serious one. On timeout, "".split("\n") yields [""], which fails the pid/ppid regex, so the map stays empty and the descendant BFS returns an empty Set. In h(), the process-group signal process.kill(-e, o) still fires and catches same-pgid children, but the for (let r of t) fallback — the branch that exists specifically to catch descendants which have left the process group (double-forked or setsid'd) — becomes a no-op.

So under load, killProcessTree does not just leak a ps; it also quietly fails to kill the very descendants it was written to catch, with no error surfaced. Those survivors enlarge the process table, which slows the next ps -A, which makes the next timeout more likely. That is a closed feedback loop and it explains the self-amplification without needing anything else.

The threshold is the 500ms constant

Measured ps -A -o pid= -o ppid= latency against total process count on one machine (689 samples):

| total processes | median latency | vs. the 500ms timeout |
|---|---|---|
| 1,000-1,049 | 0.06s | ~8x margin |
| 1,050-1,099 | 0.16s | occasional breach (max 0.72s) |
| 1,100-1,149 | 0.37s | at the line (max 2.33s) |
| 1,150-1,199 | 3.76s | every call breaches |
| 1,300+ | 21.7s+ | collapse |

Degradation onset lines up with the point where ps -A starts exceeding 500ms. Below it everything is fine; above it every killProcessTree call leaks a ps and performs a partial kill.

On the pipe-buffer hypothesis

I don't think the pipe buffer is the mechanism, at least not the initial one. ps -A -o pid= -o ppid= emits exactly 12 bytes per process, so at 1,100 processes the total output is ~13 KB — under a 16 KB pipe buffer. The stdout.on("data") handler also stays attached for the child's whole life (nothing outside g()'s closure can detach it), so the pipe keeps draining even after the race is abandoned. And the observed latency curve is smooth and monotonic rather than the step you'd expect from a buffer deadlock. A blocked write may well be the terminal mode on much larger tables, but the damage starts earlier and is explained by the 500ms timeout alone.

One thing worth ruling out for anyone else debugging this

It is easy to assume this is a periodic poll — the symptom looks like one. It isn't. I sampled the live process table 173 times over 210 seconds on a machine with 37-49 concurrent claude processes, matching the exact argv, and saw zero instances, against 69-138 expected if each session polled every ~2s. (Detector validated first against a deliberately SIGSTOPped ps -A -o pid= -o ppid=, which it caught immediately.) All 145 setInterval sites in the binary were extracted and windowed; none contains this call. The high concurrency reported here comes from long duration under congestion plus abandonment, not from spawn frequency — at ~1.5 spawns/sec each lasting ~45s, steady-state is ~67 in flight.

Why this may not be showing up in your own telemetry

The same chunk reports errors via:

function f(e,o){ ... s("tengu_bash_tool_kill_error", {stage: c(e), ...r && {error_code: r}}) }

f() is called for "enum_spawn" (the ps spawn threw) and "group_kill". The timeout path calls nothing — it just resolves the race with "" and proceeds through a normal-looking success path. So the common case on a loaded machine, where ps spawns fine but takes longer than 500ms, emits no error event at all, and the resulting partial kill is indistinguishable from a clean one. That blind spot may be why the failure mode has been hard to see from the inside.

Suggested fixes, in order of value

  1. Kill the child on timeout. Hoist the ChildProcess out of g()'s closure so w() can t.kill() when the race times out. Fixes the leak directly.
  2. Do not treat a timeout as an empty descendant set. Distinguish "no descendants" from "enumeration failed", and on failure either retry, fall back to a cheaper enumeration, or at minimum surface that the tree kill was partial rather than reporting success.
  3. Reconsider a fixed 500ms budget for a whole-process-table walk. It is a machine-wide quantity that scales with total system load, so the timeout is guaranteed to start firing on exactly the busy machines where a correct tree kill matters most.
  4. Consider sysctl kern.proc.all / libproc instead of forking ps — no subprocess to leak, no pipe, and no fork/exec cost on an already-contended machine.

Happy to run any specific measurement on a machine that reproduces this.

TianfeiXu · 26 days ago

Another reproduction on macOS, with what looks like the missing piece: what calls killProcessTree often enough to feed the loop. In my case it was the status line's refreshInterval, and a one-line settings change stopped the storm within seconds without closing any session.

Setup

16 GB M2 MacBook Air, about 19 concurrent Claude Code sessions (2.1.274 / 2.1.275), about half of them idle for two days. statusLine was a Python script with "refreshInterval": 1.

At the worst point: load average 288, 2,105 ps -A -o pid= -o ppid= processes (1,670 in state U), user process count at kern.maxprocperuid (2666), and new SSH logins failing with fork failed: resource temporarily unavailable. Every claude process had 70–135 ps children, including the sessions that had been idle for two days. Idle sessions make no tool calls, which ruled out the Bash tool as the trigger.

The caller: status line refresh → abort → killProcessTree

From the 2.1.274 bundle (I re-checked the same chain in 2.1.278):

  • The status line controller re-runs the command every max(1, refreshInterval) * 1000 ms. Each run starts with this.#a?.abort(), which aborts the previous run if it is still in flight.
  • The status line command goes through the same executor as command hooks (executeStatusLineCommand → the shared hook runner). That runner wraps the child in the shell-command class and passes the abort signal to it. On abort the class calls kill(), which calls killProcessTree → ps -A -o pid= -o ppid= with the 500 ms race described above.

So ps is spawned by claude itself even though the trigger is the status line. This is why the parent PID is claude and not a shell (the original report ruled out statusline/hooks for that reason).

On a healthy machine the status line script finishes in tens of milliseconds, is never aborted, and no ps is spawned. Once the machine is slow enough that the script takes more than one refresh interval, every tick of every session aborts the previous run and leaves a ps behind. With about 19 sessions, that is up to 19 abandoned ps per second. The loop then sustains itself, which matches the self-amplification described above. It also explains why the reported spawn frequency was about one per session every few seconds.

Controlled experiment

I sampled the machine every ~20 s for two minutes before changing anything: ps count stayed between 55 and 78 with no downward trend, and there were about 15 in-flight statusline processes. Then I removed only statusLine.refreshInterval from ~/.claude/settings.json. Settings hot-reload, so no session was restarted:

| time after change | load (1m) | ps -A -o pid= -o ppid= | in-flight status line scripts | ps -A latency |
|---|---|---|---|---|
| before | 260–289 | 55–78 | ~15 | 0.56–1.66 s |
| +30 s | 217 | 0 | 0 | 0.21 s |
| +2.5 min | 68 | 0 | 0 | 0.23 s |
| +5.5 min | 5 | 0 | 0 | 0.03 s |

Workaround for anyone hitting this: remove refreshInterval or raise it (we now use 5). This takes effect without restarting sessions. It only widens the threshold, though. A script slower than the interval, or bursts of event-driven refreshes in an active session, can still trigger the same abort → leak path.

Still present in 2.1.278

The killProcessTree enumeration (Promise.race against a hard-coded 500 ms, child not killed on timeout) is byte-for-byte the same in 2.1.275 and 2.1.278.

Suggestion specific to the status line

In addition to the fixes suggested above, the status line refresh probably should not tear down a still-running command on every tick. It could skip the tick while a previous run is in flight, or let it finish and drop its output. Otherwise a slow machine turns a cosmetic periodic refresh into a steady stream of process-tree kills.

herzkerl · 21 days ago

@TianfeiXu Can confirm — after removing the 1s refreshInterval from my config, the issue no longer shows.