[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
This issue has 8 comments on GitHub. Read the full discussion on GitHub ↗