fix: kill entire CLI process tree on stop/forceStop (Windows) - #2073
fix: kill entire CLI process tree on stop/forceStop (Windows)#2073rinceyuan wants to merge 1 commit into
Conversation
|
@microsoft-github-policy-service agree company=Microsoft |
|
@stephentoub This fixes the Windows process tree leak reported in #1804. Affects both Node.js and Python SDKs — each stop()/forceStop() cycle was orphaning the CLI's child processes. The fix uses \ askkill /T\ on Windows. Manually verified on Windows 11. Happy to add the Go/.NET fixes in a follow-up if desired. |
|
The way this is implemented in the PR currently won’t work because:
There is a small, coherent cross-language change we could accept: add one private “terminate owned runtime tree” operation per SDK, called from the existing owned-process termination point. Its behavior should be: POSIX also needs one small spawn-time change to place the runtime in its own process group/session. Otherwise group termination could kill the host. No public API is needed. Per language, this is approximately:
Each SDK should call that helper from the process-termination section used by This is about the smallest useful implementation across all languages and OSes:
The tests can also be narrow: start a helper process that starts one long-lived child, then verify both disappear after |
|
I'll move this back to draft, but please mark as ready to review if it later becomes ready. |
6fc9770 to
4364b34
Compare
|
@SteveSandersonMS Reworked per your feedback. Single commit, all 6 SDKs: Spawn-time isolation:
Teardown (private helpers, no public API):
Removed the public \processGroup\ option. External-server and in-process (FFI) paths are not affected. |
Add a private kill-process-tree helper to each SDK, called from the existing owned-process termination points in stop() and forceStop(). Spawn-time isolation (POSIX): - Node.js: detached: true - Python: start_new_session=True - Go: SysProcAttr.Setpgid = true - Rust: process_group(0) Teardown: - Windows (all): taskkill /T /F /PID - Node.js/Python/Go (POSIX): kill(-pid, SIGKILL) — process group signal - Rust (POSIX): libc::kill(-pid, SIGKILL) - Java: ProcessHandle.descendants() snapshot + destroyForcibly each - .NET: already uses Kill(entireProcessTree: true) — no change needed No public API changes. External-server and in-process (FFI) paths are not affected. Closes github#1804
4364b34 to
6ffcb43
Compare
|
@SteveSandersonMS Ready for re-review. All 5 points from your feedback are addressed — private helpers in all 6 SDKs, POSIX spawn isolation, no public API, guarded by isExternalServer. Also fixed a Rust compile issue (replaced libc::kill with kill command to avoid adding a new dependency). |
SteveSandersonMS
left a comment
There was a problem hiding this comment.
The cross-language direction is right, but this revision is not ready to merge. Rust does not compile, the existing Node lifecycle test fails, tree-kill failures can still be reported as success, and there is no process-tree coverage. I also manually exercised the public Node API: stop()/forceStop() remove a normal descendant, but stop() leaves a descendant that ignores SIGTERM. Please keep the private cross-language design, make final teardown definitive and error-aware, preserve Go's concurrency-safe process ownership, add stop()/forceStop() process-tree tests on Windows and POSIX (plus external/in-process negative coverage), and update the stale PR description.
| if let Some(mut child) = self.inner.child.lock().take() { | ||
| force_kill_process_tree(&mut child); | ||
| } | ||
| } |
There was a problem hiding this comment.
This extra closing brace makes the Rust SDK fail to compile (unexpected closing delimiter). Please fix this and run the Rust build before marking ready again.
| ["taskkill", "/T", "/F", "/PID", str(pid)], | ||
| capture_output=True, | ||
| timeout=5, | ||
| ) |
There was a problem hiding this comment.
subprocess.run() does not raise when taskkill exits nonzero, so this path can silently leave the whole tree alive and skip the fallback. Check the return status (for example with check=True) and surface or explicitly handle failure rather than returning success-shaped behavior.
| } | ||
| // POSIX: signal the process group (negative PID). | ||
| try { | ||
| process.kill(-pid, signal); |
There was a problem hiding this comment.
The default stop() path sends SIGTERM and waits only for the root. I manually tested a runtime descendant that ignores SIGTERM: the root exited, stop() completed, and the descendant remained alive. Since runtime.shutdown has already completed, final owned-tree teardown should be definitive (or follow SIGTERM with an unconditional group SIGKILL check).
| // This unblocks any I/O Start is doing (connect, version check). | ||
| if p := c.osProcess.Swap(nil); p != nil { | ||
| p.Kill() | ||
| if c.process != nil { |
There was a problem hiding this comment.
This newly reads c.process outside startStopMux, while ForceStop deliberately uses the atomically swapped osProcess to interrupt a concurrent Start. That introduces a race and may target a different process than p. Make the tree-kill helper operate from the atomically owned *os.Process/PID instead of consulting c.process here.
| except Exception: | ||
| try: | ||
| proc.kill() | ||
| except Exception: |
| except (ProcessLookupError, PermissionError, OSError): | ||
| try: | ||
| proc.kill() | ||
| except Exception: |
Summary
Fix process tree leak on Windows when
stop()/forceStop()terminates the CLI.On Windows,
ChildProcess.kill()(Node.js) andPopen.terminate()/kill()(Python) only terminate the immediate process viaTerminateProcess(), leaving grandchildren orphaned. Eachcreate_session()/stop()cycle leaks a full copilot process tree (~3-5 processes) that survives until idle timeout or OOM.Changes
client.ts)killProcessTree()helper: usestaskkill /T /F /PIDon Windows, falls back to standard.kill(signal)on Unixclient.py)_kill_process_tree()helper: usestaskkill /Ton Windows,os.killpg()on Unix, with fallback toproc.kill()Validation
tsc --noEmit)ast.parse)taskkill /Tkills both parent and grandchild (see test output below)parent=47632 child=27936 pre:parent alive pre:child alive taskkill OK post:parent DEAD(OK) post:child DEAD(OK)Notes
os.killpg()in Python (requires the child to be a process group leader); falls back gracefully if not applicable.Closes #1804