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. |
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: |
6ffcb43 to
0b74510
Compare
|
Pushed fixes for all 4 inline comments:
Still TODO: process-tree tests. Working on those next. |
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
0b74510 to
747a755
Compare
|
@SteveSandersonMS All feedback addressed + process-tree tests added:
Ready for re-review. |
Summary
Fix process tree leak when
stop()/forceStop()terminates the CLI process. On WindowsChildProcess.kill()/Popen.terminate()only terminates the root, leaving grandchildren orphaned. On POSIX a SIGTERM-resistant descendant survives after the root exits.Approach
Spawn-time isolation — put the CLI in its own process group so we can signal the entire tree:
detached: truestart_new_session=TrueSysProcAttr{Setpgid: true}process_group(0)Teardown — private helpers (no public API change), called from
stop()andforceStop():taskkill /T /F /PIDkill(-pid, SIGKILL)(process group signal)ProcessHandle.descendants().forEach(destroyForcibly)Kill(entireProcessTree: true)— no changeError awareness — Python checks
taskkillreturncode and falls back toproc.kill(); Node escalates SIGTERM → wait → SIGKILL on the group.Concurrency safety — Go's
killProcessTreeByPidoperates on the PID from the atomically-swappedosProcess, not the mutex-guardedc.process.Tests
New
nodejs/test/process_tree_kill.test.tswith 3 test cases:taskkill /Tkills bothValidation
tsc --noEmitclean}that broke compilationCloses #1804