[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

Status Open
Reported on v2.1.150
Maintainer reply None cached
Activity 8 comments · opened May 27, 2026

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 ↗

8 Comments

JustinCMR · 3 months ago

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.exe across two trees, on Windows 11 with Claude Code 2.1.149 + Claude Desktop 1.9255.2.0:

bash 22640 (started 2026-05-24 20:12:51, age 13h17m, parent PID 24188 dead)
  bash 3872 (eval'd payload)
    bash 12512 (gh wrapper, transient)

bash 26516 (started 2026-05-25 08:29:39, age 1h00m, parent PID 34192 dead)
  bash 27448 (eval'd payload)
    bash 33412 (gh wrapper, transient)

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):

RUN_ID=$(gh run list --repo <org>/<repo> --branch <feature-branch> --limit 1 \
         --json databaseId --jq ".[0].databaseId")
echo "Watching PR run $RUN_ID"
until gh run view "$RUN_ID" --repo <org>/<repo> --json status --jq .status \
      | grep -q "^completed$"; do true; done
echo "PR CI: $(gh run view "$RUN_ID" --repo <org>/<repo> --json conclusion --jq .conclusion)"

The model wrote a "create PR then watch its CI" helper. RUN_ID resolved to empty string (the branch had no CI run yet, or the run was never created), so gh run view "" returned exit 1 forever. The until ... grep -q "^completed$" condition could never match. do true; done provided zero throttling.

Spawn rate measurement

WMI snipe of running orphans captured roughly 8 gh.exe spawns 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.blur on the Claude Desktop composer. Sentry breadcrumbs showed:

HH:MM:SS.210 [child_process] Child process exited with code 1 <- gh.exe
HH:MM:SS.233 [child_process] Child process exited with code 1 <- gh.exe
HH:MM:SS.651 [child_process] Child process exited with code 1 <- gh.exe
HH:MM:SS.893 [electron] window.blur
HH:MM:SS.893 [electron] app.browser-window-blur
HH:MM:SS+1.345 [electron] window.focus

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> -Force on 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

  • The originating sessions had been gone for hours. No session-level cleanup ever ran.
  • Claude Desktop itself auto-updated during the session (1.9255.0.0 to 1.9255.2.0) which restarted the main process. The orphan bashes survived even the parent Electron process restart.
  • The model-side question (generating busy-wait loops without sleep or RUN_ID validation) is a separate concern. The job-object issue is what allowed an inert dead loop to keep DOSing the host for 13 hours.

Environment

  • Windows 11
  • Claude Code 2.1.149
  • Claude Desktop 1.9255.2.0 (Microsoft Store install)
  • Git for Windows, GitHub CLI authenticated
deity3005 · 3 months ago

Confirming this defect also affects MCP child processes spawned by claude --print, not just Bash/PowerShell tool descendants.

Reproducer (headless --print from a Python wrapper)

Any operator with one or more stdio MCP servers configured in ~/.claude.json or a project .mcp.json, invoked via:

proc = subprocess.Popen(
    ["claude.exe", "--print", "--output-format", "stream-json", ..., prompt],
    stdin=PIPE, stdout=PIPE, stderr=PIPE,
)
# ... read stream-json ...
proc.wait()

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 mixed dotnet-based and node-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: CreateJobObjectWSetInformationJobObject(JOBOBJECT_LIMIT_KILL_ON_JOB_CLOSE)AssignProcessToJobObject(claude.exe handle), then CloseHandle(job) in the wrapper's finally block. With KILL_ON_JOB_CLOSE set and no SILENT_BREAKAWAY_OK flag, the MCP child processes (including grandchildren — e.g. bash → dotnet run → dotnet chains for wrapper-launched MCPs) correctly inherit the job and get reaped at handle-close. This confirms the fix you describe in Defect 1 (drop 0x1000) works for the MCP-spawn path too, not just pwsh.

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 --print mode put claude.exe outside the Claude session job (analogous to Defect 2 with bash.exe in the OS interactive-session job), in which case --print operators would still need the workaround until a --print-mode job exists?

Happy to share specific job-flag measurements from the --print invocation if useful — just let me know what would help.

sorfeb · 3 months ago

Hit this on Windows 11 (Claude Code 2.1.x). Concrete repro that matches the SILENT_BREAKAWAY_OK analysis here exactly:

  1. Started a Vite dev server via the Bash tool with run_in_background: truepnpm dev (which spawns npmnode/Vite bound to port 3000).
  2. Called the TaskStop tool on that background task. It reported Successfully stopped task: ... (pnpm dev).
  3. The port was still servinglocalhost:3000 remained reachable.
  4. Get-NetTCPConnection -LocalPort 3000 -State Listen showed a live node process (the actual Vite server, cmdline vite/bin/vite.js dev --port 3000) whose parent shell was already dead.
  5. Only an explicit kill-by-port (Get-NetTCPConnection -LocalPort 3000 | Stop-Process -Force) freed it.

So TaskStop reaped only the explicitly-assigned pnpm parent; the auto-ejected node grandchild 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 (without SILENT_BREAKAWAY_OK on the kill path) would fix the dev-server case cleanly.

leifclaesson · 2 months ago

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.exe that never get reaped. Machine goes sluggish, Task Manager takes seconds to open, CPU ~15%. Cost is process/handle-table overhead, not CPU or RAM.

1,114  leaked conhost.exe (one session), ~1,110 idle
1,548  total processes (baseline ~300-400)
10.4G  working set held
~132k  handles
6,729  threads

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:

conhost.exe --headless --width 80 --height 24 --signal 0x11ac --server 0x13e0

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:

Get-CimInstance Win32_Process | ? Name -eq conhost.exe |
  group { (Get-Process -Id $_.ParentProcessId -ea 0).ProcessName } |
  sort Count -desc | select Count,Name -First 5

Workaround: restart the session (frees all at once), or sweep one session:

Get-CimInstance Win32_Process -Filter "Name='conhost.exe' AND ParentProcessId=<pid>" |
  % { Stop-Process $_.ProcessId -Force -ea 0 }

Fix: job object (KILL_ON_JOB_CLOSE) only covers kill-on-exit. These pile up while claude.exe is alive, so that won't stop in-session growth. Need ClosePseudoConsole / node-pty dispose per invocation, job object as crash backstop.

invictumhr · 2 months ago

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 leaked conhost.exe handles pile up in the Desktop Window Manager. One multi-day session:

| | leaked conhost | dwm.exe handles | 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 to Claude.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:

$clients = @(Get-CimInstance Win32_Process | ? { $_.Name -match '^(powershell|pwsh|bash|sh|cmd|wsl|php|node|git)\.exe$' })
Get-CimInstance Win32_Process -Filter "Name='conhost.exe'" | % {
  $c = $_; $age = ((Get-Date) - $c.CreationDate).TotalMinutes
  $nearLive  = $clients | ? { [math]::Abs(($_.CreationDate - $c.CreationDate).TotalSeconds) -le 10 }
  $parent    = Get-Process -Id $c.ParentProcessId -ea 0
  $liveShell = $parent -and $parent.ProcessName -notin 'claude','explorer'
  if ($age -gt 30 -and -not $nearLive -and -not $liveShell) { Stop-Process $c.ProcessId -Force -ea 0 }
}

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.

bacomalex · 2 months ago

Additional data point: pure conhost.exe accumulation, and a contrast that points squarely at the missing Job Object

Seeing the same root cause from a different angle on Windows 11 + Claude Desktop, WSL2 backend. After ~19h in a single session:

  • 144 orphaned conhost.exe, each parented to the long-lived claude.exe desktop process, ~17,600 open handles total, ~1.2 GB RAM. None are freed until the app restarts.
  • Notably, the WSL VM itself was completely clean — no leaked bash/rg/git children inside the distro. The accumulation was purely the Windows-side conhost.exe that 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.exe parented to claude.exe:

| Path | Surviving claude.exe-parented conhost.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.exe only 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.

sorfeb · 2 months ago

@bcherny pls fix

yanchenko · 1 month ago

+1, hit this on Windows 11 too: an orphaned git-bash find.exe (a runaway find / 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.