[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

Open 💬 8 comments Opened May 27, 2026 by jdu2600

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) and pwsh (PowerShell tool)

What I observed

  • A tail orphan alive 14.7 h after its parent shell exited; live bash.exe processes 15.5 h and 18.9 h old.
  • A cargo check orphan (parent session long dead) holding D:\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_background only 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.

  1. Claude session job — clear SILENT_BREAKAWAY_OK (0x1000). It already reaps the assigned pwsh itself via KILL_ON_JOB_CLOSE on session end; clearing the SILENT bit makes pwsh's descendants stay in it (verified: BREAKAWAY_OK alone keeps no-flag children in the job) so they die with the session too. Keep BREAKAWAY_OK for deliberate opt-in breakaway.
  2. **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_CLOSE and no silent breakaway, AssignProcessToJobObject the 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

  1. Windows, Claude Code. Run a Bash tool command that spawns a child, e.g. tail -f somefile & or any cargo build.
  2. Let the command complete (or kill the launching shell, or end the session).
  3. Observe the spawned process still running (Task Manager / Get-CimInstance Win32_Process), parent gone, not reaped.
  4. 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 IsProcessInJob it'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 remove fails
  • #20369 orphaned subagent leaks when parent terminal terminated
  • #40927 (closed) MCP child processes not killed on -p session 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.exe hold .claude.json.lock → CLI hangs

Process accumulation / no cleanup on completion (Windows):

  • #62107 Cowork scheduled tasks leave claude.exe alive 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

View original on GitHub ↗

This issue has 8 comments on GitHub. Read the full discussion on GitHub ↗