[Windows] Cancelled statusLine commands leave permanently suspended child processes

Status Open
Maintainer reply None cached
Activity 3 comments · opened Oct 2, 2026

Environment: Windows 11 23H2, Git Bash installed (statusLine runs through Git Bash), Claude Code 2.1.x

What happens
When a newer status-line update cancels an in-flight statusLine command, the native child it was starting (e.g. python.exe statusline.py) sometimes stays behind forever: its parent sh.exe is gone, it has one thread with WaitReason=Suspended, 0 CPU time and 0 MB working set. It never runs a single instruction, so no in-script watchdog can end it. They pile up over a day (5 found on one machine in a few hours).

Likely cause
MSYS2/Cygwin starts native children CREATE_SUSPENDED and resumes them after its bookkeeping. If the cancel kills the process tree from a snapshot (taskkill /T style), a child created between snapshot and kill is missed; its parent dies before resuming it, so it stays suspended.

Reproduction (harness, 300 runs per cell, random kill after 0-120 ms)

| command run via bash.exe -c | kill direct child only | snapshot tree kill |
|---|---|---|
| python directly, no bash (control) | 0 / 300 | 0 / 300 |
| sh wrapper.sh → python | 0 / 300 | 9 / 300 suspended python.exe |
| "<python.exe>" script.py | 0 / 300 | 8 / 300 suspended python.exe |
| py -3 script.py | 0 / 50 | 1 / 50 suspended py.exe |

Removing the wrapper or using py.exe does not help; any MSYS→native spawn can strand. Nothing on the user side fixes it.

Suggested fix
Run each statusLine / hook / Bash-tool shell in its own Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and no breakaway, and cancel with TerminateJobObject. Children inherit the job at creation, even while suspended, so there is no snapshot race.

Related: tail -f started from Bash-tool commands also survives after its shell is killed (running, not suspended) — the same job-object approach would cover it.

View original on GitHub ↗

3 Comments

kmorgan-r · 10 days ago

Same bug on a second machine, with more data. Windows 11 Pro 10.0.26200, Claude Code 2.1.289 (native installer), Git for Windows, Node 24.20, about 10 concurrent sessions.

  • Scale: over six days, 382 node.exe piled up with exactly the signature described above (1 thread, Wait/Suspended, 0 CPU time, 0 modules loaded), plus 98 orphaned cygwin-console-helper.exe, each with its conhost.exe. Together about 1 GB of working set. A one-time cleanup today killed 410 + 101 + 101.
  • Not only statusLine: 348 were statusline scripts, but shell-form hooks strand too: project hooks (26) and plugin hooks (*.js hooks from two plugins, a codex session-lifecycle-hook.mjs, and 3 python3.13.exe from the security-guidance plugin). One rg.exe as well.
  • Exec-form hooks never strand. On the same machine a hook in command + args form runs on every turn and has 0 leftovers.
  • The cancel is a tree kill: a live session was caught running taskkill.exe /PID <bash pid> /T /F as a child of claude.exe.
  • Bun repro: spawn bash -c "node ..." from Bun (Claude Code's runtime) and kill it after a random 10 to 150 ms. subprocess.kill() (TerminateProcess on bash): 0 strands in 2,250 runs, under both Node and Bun. taskkill /pid <bash> /T /F: 3 of 300, 0 of 100, and one batch that left 50. The snippet below left 0 of 300 today in a run with the reaper (below) paused; two earlier runs today also counted 0, but a session-start reaper may have cleared strands before the count. The window looks timing- and load-dependent, so the harness numbers in the issue body are the better repro.
// bun repro.mjs  (Windows, Git for Windows installed). Leaves stranded node.exe behind on purpose.
import { execFileSync } from 'node:child_process';
const BASH = 'C:\\Program Files\\Git\\bin\\bash.exe';
const sleep = ms => new Promise(r => setTimeout(r, ms));
for (let i = 0; i < 300; i++) {
  const p = Bun.spawn([BASH, '-c', 'node -e "0" strand-repro'], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', windowsHide: true });
  await sleep(10 + Math.random() * 140);
  try { execFileSync('taskkill', ['/pid', String(p.pid), '/T', '/F'], { stdio: 'ignore', timeout: 10000 }); } catch {}
}
await sleep(3000);
console.log('stranded:', execFileSync('powershell', ['-NoProfile', '-Command',
  "(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ? CommandLine -match 'strand-repro').Count"]).toString().trim());
process.exit(0);

Clean up afterwards: Get-CimInstance Win32_Process -Filter "Name='node.exe'" | ? CommandLine -match 'strand-repro' | % { Stop-Process -Id $_.ProcessId -Force }

statusLine exec form does not work on 2.1.289. A project statusLine of {"type": "command", "command": "node", "args": ["<probe>.cjs", "${CLAUDE_PROJECT_DIR}"]} (the form hooks accept) left the status line blank, with no settings warning, and the probe script never ran: its log was never written. So besides the Job Object fix above, accepting args on statusLine as hooks already do would take the shell, and the strand, out of statusline runs. Shell-form hooks still need the Job Object fix.

Workaround in use: an async SessionStart hook in exec form that kills node.exe, python*.exe and rg.exe with one suspended thread, 0 CPU time and an age over 120 s, plus cygwin-console-helper.exe whose parent is gone. Each kill re-checks the process start time, so a reused PID is never hit.

axlaser · 7 days ago

Measurements from a status-line binary (claude-statusline) on Windows 11 with Claude Code 2.1.292, Git for Windows 2.55 and refreshInterval: 1.

What leaks. Cancelling an in-flight status-line run with taskkill /PID <Git\bin\bash.exe> /T /F strands two process shapes that never exit:

  1. The status-line command, created suspended and never resumed. MSYS2 creates every native child with CREATE_SUSPENDED and resumes it a few milliseconds later (spawn.cc, msys2-runtime 3.6.9). A kill that lands inside that window leaves the child with one thread, zero CPU time and no parent. It keeps its image mapped, so the binary can't be replaced or deleted until something terminates it.
  2. cygwin-console-helper.exe <hello> <goodbye> plus its own conhost.exe. /T kills the run's console host before bash. Bash, now without a console, starts the helper through create_invisible_console_workaround (fhandler/console.cc) with CREATE_NEW_CONSOLE. The helper signals the first event and waits forever on the second. If bash dies before signalling, the helper and its console host stay.

How often.

  • In an 11-minute live sample there were 993 taskkill runs, and 14 of 119 status-line runs (12%) leaked a process.
  • In the same sample, no hook run and no subagentStatusLine run leaked. The status line is the run that gets cancelled on every refresh trigger.
  • Over about a week of normal use, one machine accumulated 116 suspended status-line processes, 160 console helpers and 160 console hosts. That came to 436 processes, about 30k handles, 760 threads and 2.4 GB of working set.

What makes it worse.

  • The second taskkill, 1.5 s after the first, runs without checking that the PID still names the same process (#96394).
  • Nothing holds the run in a Job Object, so /T walks a snapshot of the tree and races process creation.
  • Anything that slows Git Bash's start, such as endpoint security scanning bash.exe, widens the window. With a 1 s refresh, a cancelled start becomes the common case.

What would fix it on the Claude Code side, in rough order of reach:

  • Start each status-line run inside a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, and cancel by terminating the job. That kills every member, including a child still suspended and a helper started after the snapshot, with no tree walk and no PID reuse.
  • If taskkill stays: check that the PID still names the launcher before the second kill, and avoid killing the console host before its children.
  • Let statusLine take the exec form hooks already accept (command plus args, no shell). A native status-line command would then skip Git Bash and MSYS2 entirely, which removes both shapes.

The Cygwin change 28122c60fa removes the console helper on Windows build 26100 and later, but Git for Windows 2.55 doesn't include it yet. The suspended-spawn window remains either way.

On our side, the status line now reclaims what it can prove is stranded: one thread, zero CPU time, parent gone or its PID reused, at least 60 s old, this user's. It runs as a hook on each prompt and when a turn goes idle. That limits the damage but doesn't fix the cause.

luckygreen · 3 days ago

Confirming on a second machine, with data.

Environment: Windows 11 Pro 26200, Claude Code 2.1.292 (native), Git for Windows 2.55.0.windows.5 (GNU bash 5.3.15, msys2-runtime 3.6.x). statusLine command "C:/Program Files/Git/bin/bash.exe" <script>.sh with refreshInterval: 1.

Observed:

  • About 188 new bash.exe processes per 20 s across a few open sessions.
  • 22 to 35 orphaned bash.exe <script>.sh processes at a time, with dead parents, no CPU and no children.
  • Every MSYS bash start raised Security event 4798 ("local group membership enumerated"), about 4/s. That overwrote two days of Security log in about 35 minutes.
  • The machine became unusable (over 30 s input latency, CPU and RAM near 100%) and needed a power-off.
  • With statusLine removed, it dropped to 6 bash.exe per 20 s.

Different from the original report: here the orphans were not suspended forever. All of them exited within about 5 minutes. That matches msys2-runtime fork.cc: a fork child waits FORK_WAIT_TIMEOUT (300000 ms) for its parent, then dies with api_fatal("WFSO timed out"). #12870 shows that message coming from a status line.

Mechanism, as far as I can tell:

  1. On cancel, Claude Code runs taskkill /PID <launcher> /T /F against Git\bin\bash.exe.
  2. That launcher (git-wrapper.c) waits on its child but has no Job Object, so the kill doesn't reach the children.
  3. /T walks a parent-PID snapshot, and MSYS fork/exec children fall outside it.

Request: +1 for the Job Object fix proposed above: one job per status line run, with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and no breakaway, closed on cancel. Nothing up to 2.1.296 addresses it.