[BUG] Windows: Bash tool (grand)children survive command completion/kill as unkillable orphans — no per-command Job Object, and `SILENT_BREAKAWAY_OK` defeats the one that exists
Summary
On Windows, processes spawned by the Bash tool (bash and everything it launches — cargo, rustc, tail, node, …) are not covered by any kill-on-close Job Object. They survive the Bash command's completion, survive killing the launching shell, and survive session end — accumulating as orphans that nothing reaps until logoff/reboot. A single hung orphan holding a file lock then wedges every other Claude session (concretely: cargo's shared .package-cache lock → 12-hour Blocking waiting for file lock hangs).
Root cause is a Job Object hierarchy problem with two distinct defects, both verified by reverse-engineering the live job objects (method + evidence below).
Measured job hierarchy
OS interactive-session job (LimitFlags 0x1800) — NOT Claude-owned; no KILL_ON_JOB_CLOSE
│
├─ Claude session job (LimitFlags 0x3C00) — KILL_ON_JOB_CLOSE + SILENT_BREAKAWAY_OK
│ │ + BREAKAWAY_OK + DIE_ON_UNHANDLED
│ ├─ claude.exe (session agent)
│ └─ pwsh (PowerShell tool) ← in the kill-on-close job, BUT SILENT_BREAKAWAY ejects its children
│
└─ bash (Bash tool) ← sits here directly: no Claude job, no kill-on-close at all
Confirmed by measurement: querying the OS interactive-session job's process list returns the session-agent claude.exe — the same process we see in the Claude session job — which means the Claude session job is a child job nested under the OS interactive-session job (a process in a nested job is reported up through every ancestor job; it isn't assigned to two independent jobs). The PowerShell-tool pwsh lives in the Claude session job (its own job-query returns members {claude.exe, pwsh}, not {pwsh} — so there is no separate pwsh job); no per-command job exists for either tool; and bash.exe sits in the OS interactive-session job only, never the Claude one.
Environment
- Claude Code: 2.1.150
- OS: Windows 11, 10.0.26200.8457
- Shells: git-bash
bash.exe(Bash tool) andpwsh(PowerShell tool)
What I observed
- A
tailorphan alive 14.7 h after its parent shell exited; livebash.exeprocesses 15.5 h and 18.9 h old. - A
cargo checkorphan (parent session long dead) holdingD:\rust\cargo\.package-cache; cargo has no lock timeout, so every other session's cargo blocks indefinitely.
Root cause (two defects)
Defect 1 — the Claude session job has kill-on-close, but SILENT_BREAKAWAY_OK ejects all descendants
The PowerShell-tool pwsh is placed in the Claude session job (LimitFlags = 0x3C00):
| bit | flag | |
|-----|------|--|
| 0x2000 | KILL_ON_JOB_CLOSE | ✅ good |
| 0x1000 | SILENT_BREAKAWAY_OK | ⛔ the problem |
| 0x0800 | BREAKAWAY_OK | |
| 0x0400 | DIE_ON_UNHANDLED_EXCEPTION | |
SILENT_BREAKAWAY_OK makes every child process leave the job automatically at creation — no CREATE_BREAKAWAY_FROM_JOB needed. So the kill-on-close job ends up governing only the one process the harness explicitly assigned, and nothing it spawns. The ejected descendants land in a bare LimitFlags=0x0 job (no kill-on-close).
It effectively flips job membership from opt-out to opt-in: nothing is enrolled unless explicitly AssignProcessToJobObject'd.
Controlled experiment (create a job with each flag, spawn a no-flag grandchild, check membership):
| job flags | grandchild stays in job? |
|-----------|:---:|
| BREAKAWAY_OK only (0x0800) | yes (only explicit CREATE_BREAKAWAY_FROM_JOB breaks away) |
| SILENT_BREAKAWAY_OK only (0x1000) | no — auto-ejected |
| no flags (0x0000) | yes |
⇒ Clearing only 0x1000 is sufficient; keep BREAKAWAY_OK — it's the explicit, opt-in escape hatch (a process must deliberately request CREATE_BREAKAWAY_FROM_JOB), the principled counterpart to SILENT's blanket auto-eject, and is never triggered by normal tool trees. (Clear it too only if you want a hard "nothing can escape" guarantee.)
Defect 2 — Bash-tool processes are in no kill-on-close job at all
The Bash-tool bash.exe is not in the Claude session job. Measured directly: a foreground bash is a member of the OS interactive-session job (LimitFlags 0x1800, no KILL_ON_JOB_CLOSE) — the ambient Windows logon job that only dies at logoff. So bash and its descendants have zero kill-on-close coverage.
- Foreground vs background: no difference — both put bash directly in the OS interactive-session job;
run_in_backgroundonly changes whether the harness waits, not the spawn/job-assignment.
Why this is distinct from #51760
#51760 reports background bash children surviving session close (with a downstream 4.86 TB task-output disk runaway + observability gaps). This issue is narrower and more fundamental: Bash (grand)children survive the command's own completion or kill — there is no per-command teardown at all — and #51760's stated root cause ("CC does not place children in a KILL_ON_JOB_CLOSE job") is imprecise: a kill-on-close job exists for PowerShell but is defeated by SILENT_BREAKAWAY_OK, and for Bash there is no such job at the relevant level.
Proposed fix
Windows 8 / Server 2012+ support nested jobs, so a process stays in its parent job and can be placed in a child job — no re-assignment, no breakaway needed.
- Claude session job — clear
SILENT_BREAKAWAY_OK(0x1000). It already reaps the assignedpwshitself viaKILL_ON_JOB_CLOSEon session end; clearing the SILENT bit makes pwsh's descendants stay in it (verified:BREAKAWAY_OKalone keeps no-flag children in the job) so they die with the session too. KeepBREAKAWAY_OKfor deliberate opt-in breakaway. - **Add a per-command nested kill-on-close job for every tool command (this is the new piece, and the only thing that covers Bash at all today): create a job with
KILL_ON_JOB_CLOSEand no silent breakaway,AssignProcessToJobObjectthe command's root process (pwsh nests under the Claude session job; bash nests under the OS interactive-session job**), and close the handle when that root process exits (foreground exit / background exit / timeout). Closing it kills the whole command tree.
Net invariant: command process exits ⇒ its job closes ⇒ zero orphans; session exits ⇒ the Claude session job closes ⇒ anything left dies. Today neither backstop reaches Bash, because bash is never placed in a Claude-owned job.
Reproduction
- Windows, Claude Code. Run a Bash tool command that spawns a child, e.g.
tail -f somefile &or anycargo build. - Let the command complete (or kill the launching shell, or end the session).
- Observe the spawned process still running (Task Manager /
Get-CimInstance Win32_Process), parent gone, not reaped. - Expected: spawned tree terminated on command completion. Actual: survives until logoff.
Method (for reviewers reproducing the job analysis)
- Read job limit flags from inside a job:
QueryInformationJobObject(NULL, JobObjectExtendedLimitInformation). - Prove breakaway: spawn a child, confirm via
IsProcessInJobit's no longer in the parent's job. - git-bash Windows PID:
/proc/$$/winpid($$is the MSYS pid, not the Windows pid). - Note: the harness serializes same-message tool calls, so a foreground Bash + a PowerShell observer don't overlap — measure within a single call (have the bash's child enumerate its own job's PID list and check for the parent bash winpid).
Related issues
Same defect — child/orphan survives teardown:
- #51760 (Windows) background bash children survive session close — closest; distinct scope
- #41742 (Windows) node/bash never released; orphans hold file locks →
git worktree removefails - #20369 orphaned subagent leaks when parent terminal terminated
- #40927 (closed) MCP child processes not killed on
-psession exit - #61060 (closed, macOS) zsh wrappers persist, block git ops — cross-platform analog
- #56783 (WSL2) dbus-daemon leaked per Bash call — Linux analog
Orphan-holds-a-lock consequence:
- #57413 (Windows) zombie
claude.exehold.claude.json.lock→ CLI hangs
Process accumulation / no cleanup on completion (Windows):
- #62107 Cowork scheduled tasks leave
claude.exealive after completion - #54626 scheduled tasks & background sub-agents leak processes/UI state
- #62165 Bash tool hangs on npx/npm, ignores timeout
- #50589 nohup process never killed
8 Comments
Real-world high-multiplier case: 6 orphan bashes from one Claude Code session, oldest alive 13h17m
Adding a concrete case to the orphan scenarios (tail, cargo) already documented here. This one originated from a model-generated busy-wait loop rather than a user command, which produced a much higher spawn multiplier than the long-lived-but-idle orphans you measured.
The orphan tree
Found 6 orphan
bash.exeacross two trees, on Windows 11 with Claude Code 2.1.149 + Claude Desktop 1.9255.2.0:Both top-level bashes had parent PIDs that were long gone. Originating claude-code sessions had exited hours earlier. Consistent with this issue's measurement that bash sits in the OS interactive-session job, not in any Claude-owned kill-on-close job.
The payload
The eval'd cmdline (sanitized, full version captured via WMI):
The model wrote a "create PR then watch its CI" helper.
RUN_IDresolved to empty string (the branch had no CI run yet, or the run was never created), sogh run view ""returned exit 1 forever. Theuntil ... grep -q "^completed$"condition could never match.do true; doneprovided zero throttling.Spawn rate measurement
WMI snipe of running orphans captured roughly 8
gh.exespawns per second, sustained. Estimated cumulative spawns over the 13h lifetime of the oldest tree: on the order of 375,000, each one a failed call against api.github.com.How it was discovered (compound with #14828)
The user noticed only because they were on Windows: each of those 8 gh.exe/sec was painting a brief console window (#14828) and firing
electron/window.bluron the Claude Desktop composer. Sentry breadcrumbs showed:Symptom from the user's POV: "keystrokes get dropped while typing in the chat composer." On Linux or macOS this same orphan tree would silently waste CPU and burn GitHub API quota with no visible signal.
Resolution
Stop-Process -Id <pid> -Forceon the 4 long-lived top bashes (transient gh wrappers died with their parents). Spawn rate dropped from ~8/sec to baseline sidebar level (~0.2/sec) immediately.Notes that support this issue's proposed fix
Environment
Confirming this defect also affects MCP child processes spawned by
claude --print, not just Bash/PowerShell tool descendants.Reproducer (headless
--printfrom a Python wrapper)Any operator with one or more stdio MCP servers configured in
~/.claude.jsonor a project.mcp.json, invoked via:When
proc.wait()returns, the MCP stdio child processes survive — reparented to System on Win 11, accumulating on every invocation. Verified on Windows 11 26200 with mixeddotnet-based andnode-based MCPs.Workaround confirms the Defect 1 fix works for MCPs too
I patched around it in the wrapper with a user-space Job Object:
CreateJobObjectW→SetInformationJobObject(JOBOBJECT_LIMIT_KILL_ON_JOB_CLOSE)→AssignProcessToJobObject(claude.exe handle), thenCloseHandle(job)in the wrapper's finally block. WithKILL_ON_JOB_CLOSEset and noSILENT_BREAKAWAY_OKflag, the MCP child processes (including grandchildren — e.g.bash → dotnet run → dotnetchains for wrapper-launched MCPs) correctly inherit the job and get reaped at handle-close. This confirms the fix you describe in Defect 1 (drop0x1000) works for the MCP-spawn path too, not justpwsh.Open question
Once Defect 1 is fixed in Claude Code, will the existing Claude session job's kill-on-close cover MCP children spawned by
claude --print? Or does--printmode putclaude.exeoutside the Claude session job (analogous to Defect 2 withbash.exein the OS interactive-session job), in which case--printoperators would still need the workaround until a--print-mode job exists?Happy to share specific job-flag measurements from the
--printinvocation if useful — just let me know what would help.Hit this on Windows 11 (Claude Code 2.1.x). Concrete repro that matches the
SILENT_BREAKAWAY_OKanalysis here exactly:run_in_background: true—pnpm dev(which spawnsnpm→node/Vite bound to port 3000).Successfully stopped task: ... (pnpm dev).localhost:3000remained reachable.Get-NetTCPConnection -LocalPort 3000 -State Listenshowed a livenodeprocess (the actual Vite server, cmdlinevite/bin/vite.js dev --port 3000) whose parent shell was already dead.Get-NetTCPConnection -LocalPort 3000 | Stop-Process -Force) freed it.So TaskStop reaped only the explicitly-assigned
pnpmparent; the auto-ejectednodegrandchild survived in the bare no-kill-on-close job, exactly as described. From the user side this reads as "stop succeeded but the server is still up," and the only reliable teardown is killing by port rather than by task ID. +1 — a per-command Job Object (withoutSILENT_BREAKAWAY_OKon the kill path) would fix the dev-server case cleanly.Repro + numbers on Windows, Claude Code 2.1.143. It's the headless ConPTY path, not just
rg.exe.One unbroken session, 62.6h uptime, leaked 1,114 child
conhost.exethat never get reaped. Machine goes sluggish, Task Manager takes seconds to open, CPU ~15%. Cost is process/handle-table overhead, not CPU or RAM.All have a live parent (the running
claude.exe) -- not crash orphans, held open for the session lifetime.Command lines show the headless pseudo-console:
One spawned per shell tool call, pseudoconsole never closed on child exit. Count tracks tool-call volume: 119/540/402 over three days.
Env: Win11 IoT 10.0.26200 x64, Ultra 9 285K, 96 GB. Not a weak box -- pure subprocess overhead. Nothing in 2.1.144-2.1.153 changelog touches process lifecycle.
Check:
Workaround: restart the session (frees all at once), or sweep one session:
Fix: job object (
KILL_ON_JOB_CLOSE) only covers kill-on-exit. These pile up whileclaude.exeis alive, so that won't stop in-session growth. NeedClosePseudoConsole/ node-pty dispose per invocation, job object as crash backstop.Same root cause, confirmed on the Microsoft Store desktop build (
Claude_1.12603.1.0, Win11 Pro 26200) — building on @leifclaesson's headless-ConPTY comment with two additions.1. The specific victim is
dwm.exe— handle exhaustion in the desktop compositor, not RAM. The leakedconhost.exehandles pile up in the Desktop Window Manager. One multi-day session:| | leaked conhost |
dwm.exehandles | total handles | RAM free ||---|---|---|---|---|
| before | 406 (381 parented to
Claude.exe, oldest 6 days) | 324,298 | 969,889 | 24.5 / 62 GB || after reaping orphans | 58 | 4,249 | 597,774 | — |
dwm fell 324k → 4.2k handles the instant the orphans were killed — so the conhost leak is what bloats the compositor. That is why the whole desktop goes sluggish and Task Manager itself shows 0 processes / hangs, with tens of GB RAM free: it is GDI/USER handle pressure in
dwm.exe, distinct from the RAM-exhaustion reports. In-session leak rate ~12 conhost/min during active tool use.**2.
Stop-Process -Name conhost/ per-parent-PID sweep is unsafe — it also kills live tool shells' conhosts.** The persistent PowerShell/Bash tool shells (and any long-running command, e.g. a 20-min test suite) also have a conhost parented toClaude.exe; a blind sweep kills those too and breaks the running session's tool calls (verified — it killed my PowerShell tool's console). A safe discriminator reaps only true orphans:Key signal: an active command's conhost has a live console-client created at the same instant; an orphan's client is dead. Protecting by that (not by age/parent alone) leaves running commands untouched regardless of duration. Strong +1 on
ClosePseudoConsole/node-pty-dispose-per-invocation — the in-session growth is the painful part the job-object backstop alone won't stop.Additional data point: pure
conhost.exeaccumulation, and a contrast that points squarely at the missing Job ObjectSeeing the same root cause from a different angle on Windows 11 + Claude Desktop, WSL2 backend. After ~19h in a single session:
conhost.exe, each parented to the long-livedclaude.exedesktop process, ~17,600 open handles total, ~1.2 GB RAM. None are freed until the app restarts.bash/rg/gitchildren inside the distro. The accumulation was purely the Windows-sideconhost.exethat each Bash/PowerShell tool call spawns. So this isn't only the grandchild-survival case from the report; the ConPTY console host alone leaks one-per-call.A diagnostic contrast that supports the "no per-command Job Object" diagnosis: I ran the same shell workload two ways and measured surviving
conhost.exeparented toclaude.exe:| Path | Surviving
claude.exe-parentedconhost.exe||---|---|
| Built-in Bash / PowerShell tools (spawned by
claude.exe) | leaks one per call || Same commands via the JetBrains MCP terminal (spawned by
phpstorm64.exe) | zero |i.e. when the spawner manages its child lifecycle, no console host is left behind; only the
claude.exe-spawned path leaks — consistent with the Job Object defects described here.Mitigation in the meantime (no app restart needed): a scheduled reaper that kills
conhost.exeonly when it has no live, co-spawned sibling client process under the same parent (the ConPTY's console host outlives its shell client) — this safely skips the consoles of still-running sessions. Happy to share the script if useful.@bcherny pls fix
+1, hit this on Windows 11 too: an orphaned git-bash
find.exe(a runawayfind /from a subagent) with its parent PID already gone — matches Defect 2 exactly. New trigger worth noting: parallel background subagents (Workflow/Task tooling), not just direct Bash commands — running several at once multiplies the odds one spawns a long-running/runaway child that outlives its own tool call.