Skip to content

fix: kill entire CLI process tree on stop/forceStop (Windows) - #2073

Open
rinceyuan wants to merge 1 commit into
github:mainfrom
rinceyuan:fix/windows-process-tree-kill
Open

fix: kill entire CLI process tree on stop/forceStop (Windows)#2073
rinceyuan wants to merge 1 commit into
github:mainfrom
rinceyuan:fix/windows-process-tree-kill

Conversation

@rinceyuan

@rinceyuan rinceyuan commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix the process-tree leak when stop() / forceStop() terminates the CLI. On Windows ChildProcess.kill() / Popen.terminate() only ends the root, leaving grandchildren orphaned. On POSIX a SIGTERM-resistant descendant survives after the root exits.

Closes #1804.

Spawn-time isolation

Put the CLI in its own process group so the whole tree can be signalled:

SDK Mechanism
Node.js detached: true
Python start_new_session=True
Go SysProcAttr{Setpgid: true}
Rust process_group(0)
Java / .NET not needed (tree-kill APIs handle it)

Teardown

Private helpers, no public API change, called from stop() and forceStop():

Platform Mechanism
Windows taskkill /T /F /PID
POSIX kill(-pid, signal) on the process group
Java ProcessHandle.descendants() collected before the root is signalled
.NET already uses Kill(entireProcessTree: true) - unchanged

stop() is graceful first and escalates: POSIX sends SIGTERM to the group, waits, then SIGKILLs the group. The escalation is unconditional because the root exiting says nothing about a descendant that ignored SIGTERM.

Windows always uses taskkill /T /F. It has no graceful signal (kill() is TerminateProcess regardless), and /T can only enumerate the tree while the root is alive, so a graceful root close would strand the descendants.

Failure handling

Every helper falls back to the single-process termination it replaced when the tree-wide path is unavailable or fails - missing pid, non-zero taskkill, ESRCH from killpg. Go propagates the error after the fallback also fails; Rust checks the exit status and reaps the root.

Go's killProcessTreeByPid uses the PID from the atomically swapped osProcess, not the mutex-guarded c.process.

Tests

nodejs/test/process_tree_kill.test.ts drives CopilotClient.stop() / forceStop() over a real spawned tree, so removing the tree termination fails them:

  1. stop() terminates descendants of the owned runtime
  2. forceStop() terminates descendants of the owned runtime
  3. stop() reaps a descendant that ignores SIGTERM (POSIX only)
  4. External-server connections are left alone

python/test_client.py gains TestKillProcessTree covering the group signal, the taskkill invocation and both fallbacks.

Validation

python -m pytest test_client.py -q                 122 passed   (ruff check/format clean)
npx vitest run test/process_tree_kill.test.ts      3 passed, 1 skipped (Windows)
  same file under WSL Ubuntu                       4 passed
npx tsc --noEmit / eslint                          clean
go vet ./... ; go test .                           clean / ok
  GOOS=linux and GOOS=darwin go build ./...        clean
mvnw test -pl sdk -Dtest=CopilotClientTest         41 passed   (spotless + checkstyle clean)
cargo +nightly fmt --check ; clippy -D warnings    clean
cargo test --all-features --lib                    226 passed

Not run locally: the internal/e2e Go package and the Rust e2e target, which need the replay harness and fail identically on a clean checkout.

@rinceyuan
rinceyuan requested a review from a team as a code owner July 24, 2026 03:33
@rinceyuan

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree company=Microsoft

@rinceyuan

Copy link
Copy Markdown
Contributor Author

@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.

@SteveSandersonMS

Copy link
Copy Markdown
Contributor

The way this is implemented in the PR currently won’t work because:

  • Python calls killpg() without starting the CLI in a separate process group, which can kill the host and sibling processes.
  • Node’s process-group behavior is opt-in and defaults to false, so normal POSIX clients retain the leak.
  • Python does not check whether taskkill succeeded.
  • The change covers only Node and Python, despite the same lifecycle requirement applying across SDKs.
  • There are no real process-tree tests, and the existing Node lifecycle test fails.

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:

Windows: taskkill /T /F /PID <root>
POSIX:   signal the runtime’s private process group

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:

SDK Spawn-time isolation Teardown
Node detached: true process.kill(-pid, signal)
Python start_new_session=True os.killpg(pid, signal)
Go SysProcAttr.Setpgid = true syscall.Kill(-pid, signal)
Rust process_group(0) signal negative PID
Java Use ProcessHandle.descendants() snapshot descendants, kill them, then root
.NET None needed existing Kill(entireProcessTree: true)

Each SDK should call that helper from the process-termination section used by stop() and forceStop(). External-server and in-proc paths must not call it.

This is about the smallest useful implementation across all languages and OSes:

  • one private helper per SDK;
  • one POSIX spawn flag per applicable SDK;
  • no public options;
  • no Job Objects;
  • no crash-cleanup guarantee;
  • no redesign of graceful shutdown.

The tests can also be narrow: start a helper process that starts one long-lived child, then verify both disappear after stop() and forceStop() on Windows and POSIX. Also verify external and in-proc modes do not enter tree termination.

@SteveSandersonMS

Copy link
Copy Markdown
Contributor

I'll move this back to draft, but please mark as ready to review if it later becomes ready.

@SteveSandersonMS
SteveSandersonMS marked this pull request as draft July 31, 2026 13:11
@rinceyuan
rinceyuan force-pushed the fix/windows-process-tree-kill branch from 6fc9770 to 4364b34 Compare August 3, 2026 02:08
@rinceyuan
rinceyuan marked this pull request as ready for review August 3, 2026 02:08
@rinceyuan

Copy link
Copy Markdown
Contributor Author

@SteveSandersonMS Reworked per your feedback. Single commit, all 6 SDKs:

Spawn-time isolation:

  • Node: \detached: true\ (always, not opt-in)
  • Python: \start_new_session=True\
  • Go: \SysProcAttr.Setpgid = true\
  • Rust: \process_group(0)\
  • Java/.NET: no spawn change needed

Teardown (private helpers, no public API):

  • Windows: \ askkill /T /F\
  • POSIX: \kill(-pid, SIGKILL)\ (process group signal)
  • Java: \ProcessHandle.descendants()\ snapshot + destroyForcibly
  • .NET: already uses \Kill(entireProcessTree: true)\ — unchanged

Removed the public \processGroup\ option. External-server and in-process (FFI) paths are not affected.

@rinceyuan
rinceyuan force-pushed the fix/windows-process-tree-kill branch from 4364b34 to 6ffcb43 Compare August 3, 2026 02:10
@rinceyuan

Copy link
Copy Markdown
Contributor Author

@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 SteveSandersonMS left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rust/src/lib.rs Outdated
if let Some(mut child) = self.inner.child.lock().take() {
force_kill_process_tree(&mut child);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread python/copilot/client.py Outdated
["taskkill", "/T", "/F", "/PID", str(pid)],
capture_output=True,
timeout=5,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread nodejs/src/client.ts
}
// POSIX: signal the process group (negative PID).
try {
process.kill(-pid, signal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread go/client.go Outdated
// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@SteveSandersonMS
SteveSandersonMS marked this pull request as draft August 3, 2026 13:00
Comment thread python/copilot/client.py Outdated
except Exception:
try:
proc.kill()
except Exception:
Comment thread python/copilot/client.py Outdated
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except Exception:
@rinceyuan
rinceyuan force-pushed the fix/windows-process-tree-kill branch from 6ffcb43 to 0b74510 Compare August 4, 2026 01:46
@rinceyuan

Copy link
Copy Markdown
Contributor Author

Pushed fixes for all 4 inline comments:

  1. *Rust extra }* — removed, structure verified
  2. Python taskkill returncode — now checks
    esult.returncode != 0\ and falls back to \proc.kill()\
  3. Node SIGTERM-resistant descendants — stop() now sends SIGTERM first, waits, then escalates to SIGKILL on the group if the root doesn't exit
  4. Go concurrency — replaced \c.process\ reads with \killProcessTreeByPid(p.Pid)\ using the atomically-swapped *os.Process\ from \osProcess.Swap(nil)\

Still TODO: process-tree tests. Working on those next.

@rinceyuan
rinceyuan force-pushed the fix/windows-process-tree-kill branch from 0b74510 to 747a755 Compare August 4, 2026 01:53
@rinceyuan

Copy link
Copy Markdown
Contributor Author

@SteveSandersonMS All feedback addressed + process-tree tests added:

  • Rust compile fix — removed extra }
  • Python taskkill error-awareness — checks returncode, falls back to proc.kill()
  • Node SIGTERM escalation — stop() sends SIGTERM, waits, then unconditionally SIGKILL's the group
  • Go concurrency — tree-kill uses PID from osProcess.Swap(nil), no mutex-external read
  • Tests — new nodejs/test/process_tree_kill.test.ts with 3 cases: POSIX group kill, Windows taskkill /T, and external-server negative case. All passing.
  • PR description updated to reflect full 6-SDK scope.

Ready for re-review.

@rinceyuan
rinceyuan marked this pull request as ready for review August 7, 2026 01:43
@rinceyuan

Copy link
Copy Markdown
Contributor Author

@SteveSandersonMS Gentle re-review ping: this is now out of draft, all requested fixes and process-tree coverage are in the single commit, and I rechecked that it still merges cleanly with current main. No further changes since the last summary.

@dariandawnblixtleo-hue dariandawnblixtleo-hue left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix

Copilot AI balanced review requested due to automatic review settings August 14, 2026 02:47
@rinceyuan
rinceyuan force-pushed the fix/windows-process-tree-kill branch from 747a755 to 2865649 Compare August 14, 2026 02:47
@rinceyuan

Copy link
Copy Markdown
Contributor Author

Rebased onto current main - the branch had gone stale against the java/src to java/sdk/src move, so the only content change from the rebase is that path.

One real fix while re-validating: force_kill_process_tree had dropped the error! log that the old force_stop emitted when start_kill() failed, which also left tracing::error unused and would have failed cargo clippy -- -D warnings. The log is back.

Verified on the rebased branch:

cargo +nightly-2026-04-14 fmt --check                       clean
cargo clippy --all-features --all-targets -- -D warnings    clean
cargo test --all-features --lib                             226 passed

git range-diff against the pre-rebase commit confirms the Node, Python and Go hunks are byte-identical to what you last reviewed. I could not re-run the Node and Python suites here because this machine cannot reach registry.npmjs.org or PyPI (TLS interception), so those rely on CI.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates SDK process teardown to terminate the full Copilot CLI process tree, addressing orphaned descendants during stop() and forceStop().

Changes:

  • Isolates spawned CLI runtimes into POSIX process groups.
  • Adds platform-specific tree termination across Node.js, Python, Go, Rust, and Java.
  • Adds Node.js process-tree termination tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
nodejs/src/client.ts Adds process-group spawning and tree termination with signal escalation.
nodejs/test/process_tree_kill.test.ts Adds POSIX, Windows, and external-server process termination tests.
python/copilot/client.py Adds session isolation and platform-specific process-tree cleanup.
go/client.go Routes normal and forced shutdown through process-tree termination.
go/process_other.go Adds POSIX process groups and group signaling.
go/process_windows.go Adds Windows taskkill /T /F termination.
rust/src/lib.rs Adds process groups and tree-kill helpers for shutdown and drop.
java/sdk/src/main/java/com/github/copilot/CopilotClient.java Adds descendant enumeration and forcible tree termination.
Suppressed comments (2)

rust/src/lib.rs:2666

  • Both platform branches in this synchronous helper discard the tree-kill command result and return immediately. If the utility fails, force_stop() and Drop do nothing and never reach the existing start_kill() fallback. Only return after a successful status; otherwise invoke the child-handle fallback and log the original failure.
fn force_kill_process_tree(child: &mut Child) {

rust/src/lib.rs:2659

  • The taskkill result is discarded and this branch returns Ok(()) even when the command cannot start or exits nonzero, so the documented child.kill() fallback is never used and stop() reports success while the CLI may remain alive. Validate status.success(), fall back on failure, and await the root child so it is reaped.
                .args(["/T", "/F", "/PID", &pid.to_string()])
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .status();
            return Ok(());

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread nodejs/src/client.ts
Comment on lines +169 to +172
const pid = child.pid;
if (pid == null) {
return false;
}
Comment thread nodejs/src/client.ts Outdated
Comment on lines +1142 to +1144
if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) {
errors.push(
new Error(
`Timed out waiting for CLI process to exit after kill: ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms`
)
);
// SIGTERM-resistant descendants may survive; escalate to SIGKILL.
killProcessTree(child, "SIGKILL");
Comment thread nodejs/test/process_tree_kill.test.ts Outdated
Comment on lines +78 to +83
// Kill process group (same as SDK does)
try {
process.kill(-parentPid, "SIGKILL");
} catch {
parent.kill("SIGKILL");
}
Comment thread nodejs/test/process_tree_kill.test.ts Outdated
Comment on lines +116 to +117
describe("CopilotClient external/in-process modes", () => {
it("should not attempt tree termination for external-server connections", async () => {
}

process.destroy();
killProcessTree(process);
Comment thread go/client.go Outdated
Comment on lines +2233 to +2234
if p := c.osProcess.Swap(nil); p != nil {
if err := p.Kill(); err != nil {
return fmt.Errorf("failed to kill CLI process: %w", err)
}
killProcessTreeByPid(p.Pid)
Comment thread rust/src/lib.rs Outdated
Comment on lines +2644 to +2648
.args(["-9", &format!("-{}", pid)])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
return Ok(());
Comment thread python/copilot/client.py
is_running = poll is None or poll() is None
if is_running:
self._cli_process.terminate()
_kill_process_tree(self._cli_process)
@rinceyuan
rinceyuan force-pushed the fix/windows-process-tree-kill branch from 2865649 to d7e353f Compare August 14, 2026 03:20
@rinceyuan

Copy link
Copy Markdown
Contributor Author

Follow-up: I got the sandbox unblocked and can now run the Node and Python suites locally, which surfaced a real defect in the Python change.

stop() was calling the tree kill directly, which replaced the graceful terminate() with an immediate SIGKILL / taskkill /F. That broke the existing test_stop_requests_runtime_shutdown_for_owned_process and diverged from the Node path you asked for (SIGTERM, wait, then escalate). The helper also called os.killpg with whatever proc.pid held, so a non-integer pid raised an uncaught TypeError instead of falling back.

_kill_process_tree now takes force and mirrors Node:

  • stop(): group SIGTERM (taskkill /T on Windows), wait, then escalate to force=True.
  • force_stop() and cleanup: force=True straight away.
  • Non-integer pid falls back to proc.terminate() / proc.kill(), so behaviour degrades to the single-process termination this replaced.

Verified on the rebased branch:

python -m pytest test_client.py -q                  117 passed
python -m ruff check / format --check copilot/client.py   clean
npx vitest run test/process_tree_kill.test.ts       3 passed
npx tsc --noEmit --skipLibCheck                     clean
cargo +nightly fmt --check / clippy -D warnings     clean
cargo test --all-features --lib                     226 passed

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
@rinceyuan
rinceyuan force-pushed the fix/windows-process-tree-kill branch from d7e353f to da078a6 Compare August 14, 2026 04:46
@rinceyuan

Copy link
Copy Markdown
Contributor Author

Thanks - all eight points were real. Fixed, and I now have Linux (WSL) and the Node/Python/Go toolchains available locally, so these are verified rather than reasoned about.

Node - killProcessTree falls back to child.kill(signal) when there is no pid. stop() now sweeps the group with SIGKILL after the SIGTERM grace period even when the root has already exited, which was exactly the orphan this PR is about.

Windows semantics - I had briefly made the graceful pass use taskkill /T without /F. That is worse than the original: the root closes but /T can only enumerate the tree while the root is alive, so the follow-up force pass can no longer reach the descendants. Windows has no graceful signal (child.kill() is TerminateProcess regardless), so the Windows path is always taskkill /T /F. Same correction applied to Python.

Node tests - rewritten. They now drive CopilotClient.stop() / forceStop() over a real spawned tree instead of calling the OS primitive, and the grandchild waits until it is actually running before reporting its pid - without that handshake the "ignores SIGTERM" case was racing its own signal handler and passing for the wrong reason. Teeth checked both ways:

  • degrade killProcessTree to child.kill(signal) -> 2 fail on Windows
  • make the SIGKILL escalation conditional again -> the SIGTERM case fails on Linux

The in-process claim was wrong, so the header and the PR body now describe the external-server case only.

Java - killProcessTree takes force; stop() keeps destroy() on the first pass and only escalates to destroyForcibly(). Descendants are collected before the root is signalled. testStopRequestsRuntimeShutdownForOwnedProcess passes again.

Go - killProcessTreeByPid returns error; killProcess() falls back to p.Kill() and propagates the failure, ForceStop falls back too.

Rust - split out signal_process_tree, which checks the exit status and returns whether it worked. kill_process_tree falls back to child.kill() and then awaits the child so the root is reaped; force_kill_process_tree falls back to start_kill().

Python - the graceful/force split you asked about, plus TestKillProcessTree covering the group signal, the taskkill invocation, and both fallbacks.

python -m pytest test_client.py -q                 122 passed   (ruff check/format clean)
npx vitest run test/process_tree_kill.test.ts      3 passed, 1 skipped (Windows)
  same file under WSL Ubuntu                       4 passed
npx tsc --noEmit / eslint                          clean
go vet ./... ; go test .                           clean / ok
  GOOS=linux and GOOS=darwin go build ./...        clean
mvnw test -pl sdk -Dtest=CopilotClientTest         41 passed   (spotless + checkstyle clean)
cargo +nightly fmt --check ; clippy -D warnings    clean
cargo test --all-features --lib                    226 passed

Still not run here: the internal/e2e Go package and the Rust e2e target, which need the replay harness and fail identically on a clean checkout.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CopilotClient.stop() leaks the CLI server's child process tree on Windows (orphaned node/copilot.exe per session)

5 participants