[BUG] Hung ps -A -o pid= -o ppid= child processes accumulate until the per-user process limit is exhausted (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?
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).
3 Comments
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 thekillProcessTreehelper. Recovered logic (minified names preserved):kis 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 theChildProcesscreated insideg()'s closure, so there is no handle available to kill it. Thepsruns 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. Inh(), the process-group signalprocess.kill(-e, o)still fires and catches same-pgid children, but thefor (let r of t)fallback — the branch that exists specifically to catch descendants which have left the process group (double-forked orsetsid'd) — becomes a no-op.So under load,
killProcessTreedoes not just leak aps; 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 nextps -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 -Astarts exceeding 500ms. Below it everything is fine; above it everykillProcessTreecall leaks apsand 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. Thestdout.on("data")handler also stays attached for the child's whole life (nothing outsideg()'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
claudeprocesses, matching the exact argv, and saw zero instances, against 69-138 expected if each session polled every ~2s. (Detector validated first against a deliberatelySIGSTOPpedps -A -o pid= -o ppid=, which it caught immediately.) All 145setIntervalsites 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:
f()is called for"enum_spawn"(thepsspawn 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, wherepsspawns 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
ChildProcessout ofg()'s closure sow()cant.kill()when the race times out. Fixes the leak directly.sysctl kern.proc.all/libprocinstead of forkingps— 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.
Another reproduction on macOS, with what looks like the missing piece: what calls
killProcessTreeoften enough to feed the loop. In my case it was the status line'srefreshInterval, 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.
statusLinewas 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 stateU), user process count atkern.maxprocperuid(2666), and new SSH logins failing withfork failed: resource temporarily unavailable. Everyclaudeprocess had 70–135pschildren, 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):
max(1, refreshInterval) * 1000ms. Each run starts withthis.#a?.abort(), which aborts the previous run if it is still in flight.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 callskill(), which callskillProcessTree→ps -A -o pid= -o ppid=with the 500 ms race described above.So
psis spawned byclaudeitself even though the trigger is the status line. This is why the parent PID isclaudeand 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
psis 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 apsbehind. With about 19 sessions, that is up to 19 abandonedpsper 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:
pscount stayed between 55 and 78 with no downward trend, and there were about 15 in-flightstatuslineprocesses. Then I removed onlystatusLine.refreshIntervalfrom~/.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 -Alatency ||---|---|---|---|---|
| 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
refreshIntervalor 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
killProcessTreeenumeration (Promise.raceagainst 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.
@TianfeiXu Can confirm — after removing the 1s refreshInterval from my config, the issue no longer shows.