[Bug] statusLine command: zombie subprocess accumulation exhausts process table
This report supersedes #31961, which I filed on 2026-Mar-07. That report incorrectly attributed the symptoms (RSS runaway, CLI crash) to the context-management-2025-06-27 beta. Controlled experiments revealed the actual root cause is described here; #31961 will be closed.
Affected versions: First observed in ≥2.1.71; reproduced on 2.1.75 (current).
Summary
When a statusLine command script is configured in ~/.claude/settings.json, Claude spawns the script's subprocesses at an extremely high rate without reaping them. The resulting zombie accumulation exhausts the process table and drives RSS into the gigabytes, rendering Claude — and eventually the entire system — unresponsive.
---
Experimental methodology
I wrote a probe script (statusline-probe.sh) that attaches to the Claude process by PID and samples RSS and direct child process count every second for 30 seconds, writing output to a timestamped file. Children are enumerated via pgrep -P <pid> '.' (the pattern argument is required on macOS for the PPID filter to apply) and retrieved in a single batched ps call. RSS is captured at start and end of the window.
All runs used:
- macOS 15, Apple Silicon
- Claude v2.1.75 (probe data collected under 2.1.73–2.1.75; session transcript recorded under 2.1.72)
- The same resumed session transcript throughout (
2c8474a0-2964-428f-8280-28c31bc5bee0): 560,906 bytes, 187 JSONL records, 82 assistant turns, 38k/200k tokens, ~4.5 hours old
Controlled variable: the presence or absence of statusLine in ~/.claude/settings.json. All other configuration was held constant.
---
Evidence
Claims are classified by epistemic status:
- Directly observed — seen or measured directly, no inference required
- Deduced with certainty — logically necessary from observed facts
- Inferred from strong evidence — high-confidence but not logically necessary
- Conjecture — plausible but not independently confirmed
---
Directly observed
- Controlled experiment:
statusLinepresent +--resumeof a large transcript → RSS grows continuously from launch; Claude becomes unresponsive within seconds to minutes.statusLineremoved →--resumeof the same session completes without incident. No other variable changed between runs.
- Fresh session stability: A fresh (non-resumed) session with
statusLineconfigured remained stable for the full 30-second observation window: 2 persistent children, RSS flat at 367M, delta +0M.
- Onset captured: A resumed session without any process throttling showed the following sequence:
| Time | RSS | Kids | Notes |
|------|-----|------|-------|
| 00:00–00:11 | 371–383M | 2 | Stable — python (basic-memory MCP) + bash only |
| 00:12 | 424M (+41M) | 4 | First invocation — gh and bash appear as live children |
| 00:13 | 500M (+76M) | 1,149 | Explosion — those PIDs now <defunct>, 1,147 more added |
| 00:15 | 597M (+97M) | 2,122 | Continued accumulation |
| End | 1,038M | — | +667M delta over 30s |
- Zombie accumulation during live runaway (not a SIGKILL artifact): All 1,147+ accumulated children were
<defunct>while Claude was still running and had not been killed. This confirms Claude is not callingwait()/waitpid()for its statusline children. (Earlier runs where<defunct>was observed after Activity Monitor sent SIGKILL cannot distinguish this; this run can.)
- Monotonic child growth confirmed: In a throttled run (
taskpolicy -b -d throttle), the full 30-second time series was captured. KIDS grew without interruption from 558 → 5,904, an average of ~185 new zombie children per second.
- System-wide fork failures: In earlier runs, unrelated processes — Slack, the macOS screenshot tool, the probe script itself — all emitted:
````
fork: retry: Resource temporarily unavailable
- Memory progression in a single unthrottled run: Activity Monitor screenshots at progressive points during one runaway session: ~250 MB → 1.12 GB → 3.77 GB → 17.13 GB.
- CPU and syscall rate: Activity Monitor during the runaway: 99.44% CPU, approximately 20 million Unix syscalls in 61 seconds.
- Babashka statusline without transcript parsing — fresh session stable (PID 89516):
2 children throughout (basic-memory python + bash), RSS 1628M → 1627M (-1M delta),
full 30-second window. No zombie accumulation.
- Babashka statusline without transcript parsing — known-repro session stable
(PID 90280): The same 82-turn, 560 KB session (2c8474a0) that reliably triggered
the runaway with the .sh script was resumed with the current .clj script (which
does not read the transcript). Result: 2 children throughout, RSS flat at 426M
(+0M delta), 30 seconds. No zombie accumulation.
- Babashka statusline without transcript parsing — second resumed session stable
(PID 91028): 2 children throughout, RSS flat at 475M (+0M delta), 30 seconds.
No zombie accumulation.
---
Deduced with certainty
statusLineis a necessary condition. The controlled experiment changed exactly one variable. Removing it eliminated the behavior; restoring it restored it.
- Claude is not reaping its statusline children. Zombie (
<defunct>) processes accumulated continuously while Claude was alive and had not been killed. A process becomes a zombie when its parent has not calledwait()/waitpid()after it exits.
- The process table was exhausted.
fork: retry: Resource temporarily unavailableis the kernel's EAGAIN response tofork(2)when the process limit is reached.
- The exhaustion was system-wide. Unrelated processes failed to fork, not just Claude.
---
Inferred from strong evidence
- The invocation rate is extremely high. The throttled run showed ~185 new zombie children per second. The statusline script spawns approximately 17–18 subprocesses per invocation (8×
jq, 4×git, 1×grep, 2×awk, 1×bash, 1×whoami, plusghappearing as a likely git credential helper). At 17 subprocesses per invocation, 185 new zombies/second implies approximately 10–11 statusline invocations per second — far beyond any reasonable polling interval.
- Large transcript amplifies the behavior. Fresh sessions (small or empty transcript) remain stable; the resumed session (560 KB, 82 turns) runs away. Transcript size may affect per-invocation cost, invocation rate, or both.
ghat onset suggests additional subprocess spawning. The statusline script does not callghdirectly. Its appearance as a live child at the moment of first invocation suggests git is spawningghas a credential helper for GitHub operations, adding to the per-invocation subprocess count.
- Transcript file I/O is the operative variable. Two different runtimes (bash and
babashka) both triggered the runaway when configured to read and grep the full
transcript file on every invocation. The current babashka script — identical in
purpose except that it does not read the transcript — is stable even when resumed
with the known-repro session (2c8474a0, 560 KB, 82 turns). Session size alone does
not explain the behavior; the per-invocation transcript read is the common factor
in all runaway cases. The previous babashka version with transcript parsing is
preserved at: https://gist.github.com/christianromney/4ded1206e3533b6a66434844b1c164b6/9bcae64fd1d0a6965a6c72af2851b625feb17ade
---
Conjecture
- Invocation mechanism: Claude may be invoking the statusline command on every internal event or render cycle rather than on a fixed timer, or with a timer interval far shorter than intended.
- Mechanism of transcript I/O harm: Reading and grepping a large, growing file at
high invocation rates may cause the runaway through one or more of: blocking the event
loop with sequential I/O, forcing repeated page faults as the transcript grows, or
exhausting file descriptors when the file is opened faster than it is closed.
- Scope: Whether this affects all
statusLineusers or only those with large transcripts, certain script patterns, or specific configurations is unknown.
---
Prior art search
| Issue | Title | Similarity | Why distinct |
|-------|-------|------------|--------------|
| #8073 (closed) | statusline.js causes infinite loop and EBADF error | Same mechanism: statusLine script, FD exhaustion, transcript reads, high subprocess spawn rate | Different runtime (Node.js v24, not Bun); older version (1.0.85); crashes Claude rather than accumulating zombies system-wide; reporter did not isolate transcript I/O as the operative variable |
| #33453 | Memory leak: Bun/WebKit Malloc unbounded growth reaching 14GB+ | macOS + Bun + Apple Silicon; rapid RSS growth (~1GB/30s) | Attributed to JSC GC not reclaiming WebKit Malloc chunks; reporter did not check child process count; no fork: retry errors reported; GC recovery observed mid-burst argues against pure zombie accumulation |
| #33342 | Native memory leak ~30GB/hour crashes server (v2.1.73) | High RSS growth rate; server OOM crashes | Linux (Ubuntu 24.04), Node.js SEA executable (not Bun); attributed to native addon (node-pty); no fork failure messages; different growth profile |
| #32660 | statusLine configuration ignored since v2.1.50 (macOS) | statusLine | Configuration silently not applied — different bug |
| #29411 | Custom statusLine command not rendered on resumed sessions | statusLine, resumed sessions | Rendering failure, not subprocess accumulation |
No existing open issue describes the specific combination: statusLine configured →
zombie subprocess accumulation → process table exhaustion → system-wide fork: retry:.
Resource temporarily unavailable
---
Statusline command script
<details>
<summary>~/.claude/statusline-command.sh</summary>
#!/usr/bin/env bash
# Claude Code status line — complements Starship / Catppuccin Frappe prompt
# Receives Claude Code session JSON on stdin
input=$(cat)
reset="\e[0m"
dim="\e[2m"
peach="\e[38;2;239;159;118m"
yellow="\e[38;2;229;200;144m"
green="\e[38;2;166;209;137m"
teal="\e[38;2;129;200;190m"
lavender="\e[38;2;186;187;241m"
sapphire="\e[38;2;133;193;220m"
mauve="\e[38;2;202;158;230m"
overlay1="\e[38;2;131;139;167m"
sep="${dim}${overlay1} ${reset}"
cwd=$(echo "$input" | jq -r '.workspace.current_dir // .cwd // ""')
model=$(echo "$input" | jq -r '.model.display_name // ""')
remaining=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty')
total_in=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty')
total_out=$(echo "$input" | jq -r '.context_window.total_output_tokens // empty')
cost_usd=$(echo "$input" | jq -r '.cost.total_cost_usd // empty')
transcript=$(echo "$input" | jq -r '.transcript_path // empty')
vim_mode=$(echo "$input" | jq -r '.vim.mode // empty')
session_name=$(echo "$input" | jq -r '.session_name // empty')
home="${HOME}"
cwd_display="${cwd/#$home/\~}"
IFS='/' read -ra parts <<< "$cwd_display"
num=${#parts[@]}
if [ "$num" -gt 3 ]; then
truncated="…/${parts[$num-3]}/${parts[$num-2]}/${parts[$num-1]}"
else
truncated="$cwd_display"
fi
git_info=""
if git --no-optional-locks -C "$cwd" rev-parse --git-dir &>/dev/null; then
branch=$(git --no-optional-locks -C "$cwd" symbolic-ref --short HEAD 2>/dev/null \
|| git --no-optional-locks -C "$cwd" rev-parse --short HEAD 2>/dev/null)
if [ -n "$branch" ]; then
dirty=""
if ! git --no-optional-locks -C "$cwd" diff --quiet 2>/dev/null \
|| ! git --no-optional-locks -C "$cwd" diff --cached --quiet 2>/dev/null; then
dirty="*"
fi
untracked=""
if [ -n "$(git --no-optional-locks -C "$cwd" ls-files --others --exclude-standard 2>/dev/null | head -1)" ]; then
untracked="?"
fi
git_info="${yellow} ${branch}${dirty}${untracked}${reset}"
fi
fi
ctx_info=""
if [ -n "$remaining" ]; then
remaining_int=${remaining%.*}
used_int=$((100 - remaining_int))
if [ "$used_int" -ge 90 ]; then color="\e[38;2;231;130;132m"
elif [ "$used_int" -ge 75 ]; then color="\e[38;2;239;159;118m"
else color="$green"
fi
ctx_info="${color}Context: ${used_int}% Used${reset}"
fi
token_info=""
if [ -n "$total_in" ] && [ -n "$total_out" ]; then
in_k=$(echo "$total_in" | awk '{printf "%.1fk", $1/1000}')
out_k=$(echo "$total_out" | awk '{printf "%.1fk", $1/1000}')
token_info="${overlay1}Tokens: ${in_k}↑ ${out_k}↓${reset}"
fi
cost_info=""
if [ -n "$cost_usd" ]; then
cost_fmt=$(echo "$cost_usd" | awk '{printf "$%.4f", $1}')
cost_info="${mauve}Cost: ${cost_fmt}${reset}"
fi
turn_info=""
if [ -n "$transcript" ] && [ -f "$transcript" ]; then
turns=$(grep -c '"role":"assistant"\|"role": "assistant"' "$transcript" 2>/dev/null || true)
[ -n "$turns" ] && [ "$turns" -gt 0 ] && turn_info="${overlay1}Turns: ${turns}${reset}"
fi
vim_info=""
if [ -n "$vim_mode" ]; then
case "$vim_mode" in
NORMAL) vim_info="${green}NORMAL${reset}" ;;
INSERT) vim_info="${sapphire}INSERT${reset}" ;;
*) vim_info="${overlay1}${vim_mode}${reset}" ;;
esac
fi
name_info=""
if [ -n "$session_name" ]; then
name_info="${dim}${overlay1}[${session_name}]${reset}"
fi
user=$(whoami)
parts_out=()
parts_out+=("${overlay1}${user}${reset}")
parts_out+=("${peach}${truncated}${reset}")
[ -n "$git_info" ] && parts_out+=("$git_info")
[ -n "$name_info" ] && parts_out+=("$name_info")
parts_out+=("${lavender}${model}${reset}")
[ -n "$ctx_info" ] && parts_out+=("$ctx_info")
[ -n "$token_info" ] && parts_out+=("$token_info")
[ -n "$cost_info" ] && parts_out+=("$cost_info")
[ -n "$turn_info" ] && parts_out+=("$turn_info")
[ -n "$vim_info" ] && parts_out+=("$vim_info")
result=""
for i in "${!parts_out[@]}"; do
if [ "$i" -eq 0 ]; then
result="${parts_out[$i]}"
else
result="${result}${sep}${parts_out[$i]}"
fi
done
printf "%b" "$result"
</details>
<details>
<summary>~/.claude/statusline-command.clj (current — no transcript parsing)</summary>
#!/usr/bin/env bb
(require '[cheshire.core :as json]
'[clojure.string :as str])
;; ── Catppuccin Frappe palette ────────────────────────────────────────────────
(def colors
"Catppuccin Frappe ANSI 24-bit color escape codes, keyed by role.
:reset and :dim are SGR attributes; all others are foreground colors."
{:reset "\033[0m"
:dim "\033[2m"
:green "\033[38;2;166;209;137m" ; #a6d189
:lavender "\033[38;2;186;187;241m" ; #babbf1
:mauve "\033[38;2;202;158;230m" ; #ca9ee6
:overlay1 "\033[38;2;131;139;167m" ; #838ba7
:red "\033[38;2;231;130;132m" ; #e78284
:peach "\033[38;2;239;159;118m"}) ; #ef9f76
;; ── Data extraction ──────────────────────────────────────────────────────────
(defn status-data
"Given `input`, a keyword-keyed map parsed from Claude Code's statusline
JSON payload, returns a map with keys:
- :model string display name; empty string if absent
- :used number, context window used percentage (optional)
- :total-in number, cumulative input tokens (optional)
- :total-out number, cumulative output tokens (optional)
- :cost-usd number, total session cost in USD (optional)
- :session-name string, human-readable session name (optional)
- :session-id string, unique session identifier (optional)
- :agent-name string, agent name when running with --agent flag (optional)"
[input]
{:model (or (get-in input [:model :display_name]) "")
:used (get-in input [:context_window :used_percentage])
:total-in (get-in input [:context_window :total_input_tokens])
:total-out (get-in input [:context_window :total_output_tokens])
:cost-usd (get-in input [:cost :total_cost_usd])
:session-name (:session_name input)
:session-id (:session_id input)
:agent-name (get-in input [:agent :name])})
;; ── Segment renderers ────────────────────────────────────────────────────────
(defn user-segment
"Given `user` (string, system username) and `colors` (map of ANSI escape
codes), returns an overlay1-colored username string.
Returns nil if `user` is blank or nil."
[user colors]
(when-not (str/blank? user)
(str (:overlay1 colors) user (:reset colors))))
(defn model-segment
"Given `model` (string display name) and `colors` (map of ANSI escape
codes), returns a lavender-colored model name string.
Returns nil if `model` is blank or nil."
[model colors]
(when-not (str/blank? model)
(str (:lavender colors) model (:reset colors))))
(defn context-segment
"Given `used` (number, pre-calculated context window used percentage) and
`colors` (map of ANSI escape codes), returns a colored context-usage
string. Color scales from green to peach to red as usage increases past
75% and 90%. Returns nil if `used` is nil."
[used colors]
(when used
(let [pct (int used)
color (cond (>= pct 90) (:red colors)
(>= pct 75) (:peach colors)
:else (:green colors))]
(str color "Context: " pct "% Used" (:reset colors)))))
(defn token-segment
"Given `total-in` and `total-out` (numbers, cumulative token counts) and
`colors` (map of ANSI escape codes), returns a formatted token-count
string with counts expressed in thousands (e.g. \"3.2k↑ 1.1k↓\").
Returns nil if either count is nil."
[total-in total-out colors]
(when (and total-in total-out)
(str (:overlay1 colors) "Tokens: "
(format "%.1fk" (/ (double total-in) 1000)) "↑ "
(format "%.1fk" (/ (double total-out) 1000)) "↓"
(:reset colors))))
(defn cost-segment
"Given `cost-usd` (number, session cost in USD) and `colors` (map of ANSI
escape codes), returns a formatted cost string (e.g. \"Cost: $0.0142\").
Returns nil if `cost-usd` is nil."
[cost-usd colors]
(when cost-usd
(str (:mauve colors) "Cost: " (format "$%.4f" (double cost-usd)) (:reset colors))))
(defn session-segment
"Given `session-name` and `session-id` (strings or nil) and `colors` (map
of ANSI escape codes), returns a dimmed bracketed string combining the
non-blank values joined by \" · \" (e.g. \"[my-session · abc123]\").
Returns nil if both are blank or nil."
[session-name session-id colors]
(let [parts (remove str/blank? [session-name session-id])]
(when (seq parts)
(str (:dim colors) (:overlay1 colors)
"[" (str/join " · " parts) "]"
(:reset colors)))))
(defn agent-segment
"Given `agent-name` (string or nil) and `colors` (map of ANSI escape
codes), returns a formatted agent-name string. Present only when Claude
Code is running with the --agent flag. Returns nil if `agent-name` is
blank or nil."
[agent-name colors]
(when-not (str/blank? agent-name)
(str (:lavender colors) "Agent: " agent-name (:reset colors))))
;; ── Assembly ─────────────────────────────────────────────────────────────────
(defn status-line
"Given keyword arguments :data (map as returned by `status-data`), :user
(string, username), and :colors (map of ANSI escape codes), returns the
assembled statusline string with non-nil segments joined by a dimmed
separator."
[& {:keys [data user colors]}]
(let [sep (str (:dim colors) (:overlay1 colors) " " (:reset colors))
parts (remove nil?
[(user-segment user colors)
(session-segment (:session-name data) (:session-id data) colors)
(model-segment (:model data) colors)
(context-segment (:used data) colors)
(token-segment (:total-in data) (:total-out data) colors)
(cost-segment (:cost-usd data) colors)])]
(str/join sep parts)))
;; ── Entry point ──────────────────────────────────────────────────────────────
(let [input (json/parse-string (slurp *in*) true)
username (System/getProperty "user.name")]
(print (status-line :data (status-data input) :colors colors :user username)))
</details>
<details>
<summary>statusline-probe.sh</summary>
#!/usr/bin/env bash
# statusline-probe.sh
#
# Diagnoses Claude CLI statusline subprocess accumulation.
#
# WHAT TO LOOK FOR:
# H1 confirmed -> KIDS column grows monotonically over time
# H1 ruled out -> KIDS stays near 0; problem is internal to Claude runtime
#
# Usage:
# statusline-probe.sh <PID> # print to stdout
# statusline-probe.sh <PID> <label> # tee to ~/Desktop/probes/<label>-<timestamp>.txt
set -euo pipefail
INTERVAL=1 # seconds between samples
TIMEOUT=30 # stop after this many seconds
OUTDIR="$HOME/Desktop/probes"
SFMT="%-10s %-7s %8s %7s %6s %s\n"
if [[ $# -lt 1 ]] || ! [[ "$1" =~ ^[0-9]+$ ]]; then
echo "Usage: $0 <PID> [label]" >&2
exit 1
fi
CLAUDE_PID="$1"
LABEL="${2:-}"
if ! kill -0 "$CLAUDE_PID" 2>/dev/null; then
echo "ERROR: PID $CLAUDE_PID does not exist." >&2
exit 1
fi
if [[ -n "$LABEL" ]]; then
mkdir -p "$OUTDIR"
OUTFILE="$OUTDIR/${LABEL}-$(date +%Y%m%dT%H%M%S).txt"
exec > >(tee "$OUTFILE")
echo "Output: $OUTFILE"
fi
START_RSS_KB=$(ps -o rss= -p "$CLAUDE_PID" 2>/dev/null | tr -d ' ' || echo 0)
START_RSS_MB=$(( START_RSS_KB / 1024 ))
echo "=================================================================="
echo " Claude Statusline Probe"
echo " PID $CLAUDE_PID interval ${INTERVAL}s timeout ${TIMEOUT}s"
echo " Start RSS: ${START_RSS_MB}M"
echo "=================================================================="
printf "\n"
printf "$SFMT" "[SAMPLER]" "ELAPSED" "RSS" "DRSS" "KIDS" "CHILD COMMANDS"
printf "$SFMT" "----------" "-------" "--------" "-------" "------" "--------------"
PREV_RSS_MB=0
START=$(date +%s)
while kill -0 "$CLAUDE_PID" 2>/dev/null; do
NOW=$(date +%s)
ELAPSED=$(( NOW - START ))
[[ $ELAPSED -ge $TIMEOUT ]] && { echo ""; echo "Probe stopped after ${TIMEOUT}s."; break; }
ELAPSED_FMT=$(printf "%02d:%02d" $(( ELAPSED / 60 )) $(( ELAPSED % 60 )))
RSS_KB=$(ps -o rss= -p "$CLAUDE_PID" 2>/dev/null | tr -d ' ' || echo 0)
RSS_MB=$(( RSS_KB / 1024 ))
RSS_STR="${RSS_MB}M"
if [[ $PREV_RSS_MB -eq 0 ]]; then DRSS_STR="--"
else
DELTA=$(( RSS_MB - PREV_RSS_MB ))
if [[ $DELTA -gt 0 ]]; then DRSS_STR="+${DELTA}M"
elif [[ $DELTA -lt 0 ]]; then DRSS_STR="${DELTA}M"
else DRSS_STR="0"
fi
fi
PREV_RSS_MB=$RSS_MB
# macOS pgrep -P requires a pattern argument to apply the PPID filter;
# without one it ignores -P and returns all processes system-wide.
CHILD_PIDS=$(pgrep -P "$CLAUDE_PID" '.' 2>/dev/null || true)
CHILD_COUNT=0
CHILD_CMDS="--"
if [[ -n "$CHILD_PIDS" ]]; then
PID_ARGS=$(echo "$CHILD_PIDS" | tr '\n' ',' | sed 's/,$//')
CHILD_DATA=$(ps -o pid=,comm= -p "$PID_ARGS" 2>/dev/null || true)
if [[ -n "$CHILD_DATA" ]]; then
CHILD_COUNT=$(echo "$CHILD_DATA" | wc -l | tr -d ' ')
CHILD_CMDS=$(echo "$CHILD_DATA" | awk '{printf "%s(%s) ", $2, $1}')
fi
fi
printf "$SFMT" "[SAMPLER]" "$ELAPSED_FMT" "$RSS_STR" "$DRSS_STR" \
"$CHILD_COUNT" "$CHILD_CMDS"
sleep "$INTERVAL"
done
END_RSS_KB=$(ps -o rss= -p "$CLAUDE_PID" 2>/dev/null | tr -d ' ' || echo 0)
END_RSS_MB=$(( END_RSS_KB / 1024 ))
RSS_DELTA=$(( END_RSS_MB - START_RSS_MB ))
[[ $RSS_DELTA -ge 0 ]] && RSS_DELTA_STR="+${RSS_DELTA}M" || RSS_DELTA_STR="${RSS_DELTA}M"
echo ""
echo " End RSS: ${END_RSS_MB}M (start: ${START_RSS_MB}M delta: ${RSS_DELTA_STR})"
echo ""
echo "Claude PID $CLAUDE_PID is no longer being watched (timeout or exit)."
</details>
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗