Windows: kill the process tree, not a POSIX process group (stopped turns orphan their MCP servers) - #43
Merged
Conversation
Stopping a turn on Windows leaves the CLI's MCP servers running. Every driver spawns its CLI with `detached: true` specifically so the CLI leads its own process group and `process.kill(-pid)` reaps the group — the CLI plus every server and helper it started. Process groups are a POSIX concept: on Windows `process.kill(-pid)` throws EINVAL, the catch falls through to `child.kill()`, and that terminates the CLI alone. Each interrupted or completed turn orphans the MCP servers it started, and they accumulate for as long as the app runs. Windows tracks a parent/child tree instead, which is what `taskkill /T` walks. killTree() picks the right one per platform and keeps the existing best-effort behaviour: it never throws, and it falls back to child.kill() if taskkill is unavailable. Used by all three drivers (claude, codex, acp/core) in place of the identical inline stop() each carried. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgJeiantdRcZSBrc5sqqCp
milind-soni
reviewed
Aug 14, 2026
milind-soni
left a comment
Owner
There was a problem hiding this comment.
Current main now centralizes this in server/procs.ts::killCliTree and all Claude, Codex, ACP, and Antigravity stop paths use it; Windows uses taskkill /T /F and POSIX uses the detached process group. This PR would duplicate that implementation, so I am leaving it unmerged as superseded.
Owner
|
Integrated through #86 on top of the current centralized process layer. The original commit is a parent of the integration merge, so authorship and history are preserved. I kept the process-tree behavior, added safe asynchronous |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's broken
Stopping a turn on Windows leaves the CLI's MCP servers running. They accumulate for as long as the app runs — every interrupted or completed turn orphans another set of node processes, holding ports, file handles and memory.
Root cause
Every driver spawns its CLI with
detached: truespecifically so the CLI leads its own process group, and then reaps the whole group:Process groups are a POSIX concept. On Windows
process.kill(-pid, ...)throwsEINVALimmediately, thecatchswallows it, and the fallbackchild.kill()terminates the CLI alone. Every server and helper the CLI started survives as an orphan.So the fallback is not a degraded path on Windows — it is the only path, and it is the wrong one.
The fix
Windows tracks a parent/child tree rather than process groups, and
taskkill /Twalks it.server/kill-tree.tspicks the right mechanism per platform behind one call:The POSIX branch is the existing code, unchanged, including its
child.kill()fallback. The win32 branch istaskkill /T /F /PID <pid>, withchild.kill()as its own fallback if taskkill is unavailable or the process is already gone.killTreekeeps the contractstop()had: best-effort, synchronous to call, never throws, and a no-op if the child has already exited.Used by all three drivers (
claude,codex,acp/core) in place of the identical inlinestop()each carried.Tests
server/kill-tree.test.tsis new: it spawns a stand-in CLI that itself spawns one helper, callskillTreeon the parent, and asserts the grandchild is gone — which is the entire point of spawning detached, and exactly what the old code failed to do on Windows. It runs on both platforms and would fail on Windows againstmain.How this was tested
pnpm typecheckandpnpm teston Windows 10: 53 passed / 33 skipped (52/33 onmain, plus the new test). No regressions.killTree, and confirmed the grandchild survives with the old inlinestop().ChildProcessobject rather than its pid, so a POSIX zombie window cannot make it flaky.Note on merge order
This touches the same
import { augmentedPath } from "../env-path.ts"lines in the three drivers as #41, and lines adjacent to the claude spawn block. Whichever lands second needs a trivial rebase — happy to do it on request, in either order.🤖 Generated with Claude Code
https://claude.ai/code/session_01HgJeiantdRcZSBrc5sqqCp