[Windows] Cancelled statusLine commands leave permanently suspended child processes
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.
3 Comments
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.
node.exepiled up with exactly the signature described above (1 thread,Wait/Suspended, 0 CPU time, 0 modules loaded), plus 98 orphanedcygwin-console-helper.exe, each with itsconhost.exe. Together about 1 GB of working set. A one-time cleanup today killed 410 + 101 + 101.*.jshooks from two plugins, a codexsession-lifecycle-hook.mjs, and 3python3.13.exefrom the security-guidance plugin). Onerg.exeas well.command+argsform runs on every turn and has 0 leftovers.taskkill.exe /PID <bash pid> /T /Fas a child ofclaude.exe.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.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
statusLineof{"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, acceptingargsonstatusLineas 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*.exeandrg.exewith one suspended thread, 0 CPU time and an age over 120 s, pluscygwin-console-helper.exewhose parent is gone. Each kill re-checks the process start time, so a reused PID is never hit.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 /Fstrands two process shapes that never exit:CREATE_SUSPENDEDand 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.cygwin-console-helper.exe <hello> <goodbye>plus its ownconhost.exe./Tkills the run's console host before bash. Bash, now without a console, starts the helper throughcreate_invisible_console_workaround(fhandler/console.cc) withCREATE_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.
taskkillruns, and 14 of 119 status-line runs (12%) leaked a process.subagentStatusLinerun leaked. The status line is the run that gets cancelled on every refresh trigger.What makes it worse.
taskkill, 1.5 s after the first, runs without checking that the PID still names the same process (#96394)./Twalks a snapshot of the tree and races process creation.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:
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.taskkillstays: check that the PID still names the launcher before the second kill, and avoid killing the console host before its children.statusLinetake the exec form hooks already accept (commandplusargs, 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.
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).
statusLinecommand"C:/Program Files/Git/bin/bash.exe" <script>.shwithrefreshInterval: 1.Observed:
bash.exeprocesses per 20 s across a few open sessions.bash.exe <script>.shprocesses at a time, with dead parents, no CPU and no children.statusLineremoved, it dropped to 6bash.exeper 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 waitsFORK_WAIT_TIMEOUT(300000 ms) for its parent, then dies withapi_fatal("WFSO timed out"). #12870 shows that message coming from a status line.Mechanism, as far as I can tell:
taskkill /PID <launcher> /T /FagainstGit\bin\bash.exe.git-wrapper.c) waits on its child but has no Job Object, so the kill doesn't reach the children./Twalks 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_CLOSEand no breakaway, closed on cancel. Nothing up to 2.1.296 addresses it.