diff --git a/go/client.go b/go/client.go index fb02897f9..7823237a8 100644 --- a/go/client.go +++ b/go/client.go @@ -656,7 +656,9 @@ func (c *Client) ForceStop() { // Kill the process without waiting for startStopMux, which Start may hold. // This unblocks any I/O Start is doing (connect, version check). if p := c.osProcess.Swap(nil); p != nil { - p.Kill() + if err := killProcessTreeByPid(p.Pid); err != nil { + p.Kill() + } } // Clear sessions immediately without trying to destroy them @@ -2231,8 +2233,10 @@ func (c *Client) killProcess() error { c.ffiHost = nil } if p := c.osProcess.Swap(nil); p != nil { - if err := p.Kill(); err != nil { - return fmt.Errorf("failed to kill CLI process: %w", err) + if err := killProcessTreeByPid(p.Pid); err != nil { + if killErr := p.Kill(); killErr != nil { + return fmt.Errorf("failed to kill CLI process: %w", killErr) + } } } c.process = nil diff --git a/go/process_other.go b/go/process_other.go index 5b3ba6353..1e64ddc25 100644 --- a/go/process_other.go +++ b/go/process_other.go @@ -2,10 +2,18 @@ package copilot -import "os/exec" +import ( + "os/exec" + "syscall" +) -// configureProcAttr configures platform-specific process attributes. -// On non-Windows platforms, this is a no-op. +// configureProcAttr places the runtime in its own process group so +// killProcessTreeByPid can signal all descendants atomically. func configureProcAttr(cmd *exec.Cmd) { - // No special configuration needed on non-Windows platforms + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// killProcessTreeByPid signals the process group (negative PID) with SIGKILL. +func killProcessTreeByPid(pid int) error { + return syscall.Kill(-pid, syscall.SIGKILL) } diff --git a/go/process_windows.go b/go/process_windows.go index 37f954fca..cab164e89 100644 --- a/go/process_windows.go +++ b/go/process_windows.go @@ -3,14 +3,19 @@ package copilot import ( + "fmt" "os/exec" "syscall" ) -// configureProcAttr configures platform-specific process attributes. -// On Windows, this hides the console window to avoid distracting users in GUI apps. +// configureProcAttr hides the console window on Windows. func configureProcAttr(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, } } + +// killProcessTreeByPid terminates the entire process tree via taskkill /T /F. +func killProcessTreeByPid(pid int) error { + return exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprintf("%d", pid)).Run() +} diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java index cdd1b9ff3..154e5889f 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -812,19 +812,19 @@ private static void cleanupCliProcess(Process process, boolean forceImmediately) // will never come just wastes time, so terminate the child // immediately and only wait to reap it. if (forceImmediately) { - process.destroyForcibly(); + killProcessTree(process, true); if (!process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { LOG.fine("Process did not terminate within force kill timeout"); } return; } - process.destroy(); + killProcessTree(process, false); if (process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { return; } - process.destroyForcibly(); + killProcessTree(process, true); if (!process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { LOG.fine("Process did not terminate within force kill timeout"); } @@ -837,6 +837,36 @@ private static void cleanupCliProcess(Process process, boolean forceImmediately) } } + /** + * Terminates the runtime's process tree, ending the descendants before the root + * so none of them are reparented and left behind. + * + * @param process + * the runtime process + * @param force + * {@code true} to terminate forcibly, {@code false} to request a + * graceful exit first + */ + private static void killProcessTree(Process process, boolean force) { + try { + // descendants() is empty once the root is gone, so collect first. + process.toHandle().descendants().toList().forEach(ph -> { + if (force) { + ph.destroyForcibly(); + } else { + ph.destroy(); + } + }); + } catch (Exception e) { + LOG.log(Level.FINE, "Error terminating process descendants", e); + } + if (force) { + process.destroyForcibly(); + } else { + process.destroy(); + } + } + /** * Creates a new Copilot session with the specified configuration. *
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index 30095186e..013826a08 100644
--- a/nodejs/src/client.ts
+++ b/nodejs/src/client.ts
@@ -11,7 +11,7 @@
* @module client
*/
-import { spawn, type ChildProcess } from "node:child_process";
+import { spawn, execSync, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
@@ -153,6 +153,42 @@ async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise
});
}
+/**
+ * Terminate the runtime's process tree.
+ *
+ * - Windows: `taskkill /T /F` kills the entire tree rooted at `pid`. The signal
+ * is ignored because Windows has no graceful equivalent and `/T` can only
+ * enumerate the tree while the root is still alive.
+ * - POSIX: the runtime is spawned in its own process group (`detached: true`),
+ * so `kill(-pid, signal)` signals every process in that group.
+ *
+ * Falls back to `child.kill(signal)` whenever the tree-wide path is unavailable
+ * or fails, so behaviour degrades to the single-process termination it replaced.
+ *
+ * @see https://github.com/github/copilot-sdk/issues/1804
+ */
+function killProcessTree(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): boolean {
+ const pid = child.pid;
+ if (pid == null) {
+ return child.kill(signal);
+ }
+ if (process.platform === "win32") {
+ try {
+ execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 });
+ return true;
+ } catch {
+ return child.kill(signal);
+ }
+ }
+ // POSIX: signal the process group (negative PID).
+ try {
+ process.kill(-pid, signal);
+ return true;
+ } catch {
+ return child.kill(signal);
+ }
+}
+
/**
* Convert tool parameters to JSON schema format for sending to CLI
*/
@@ -1104,8 +1140,13 @@ export class CopilotClient {
this.cliProcess = null;
try {
if (child.exitCode == null && child.signalCode == null) {
- child.kill();
- if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) {
+ killProcessTree(child, "SIGTERM");
+ const rootExited = await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS);
+ // The root exiting says nothing about descendants that ignored
+ // SIGTERM, and they are the orphans this is meant to prevent, so
+ // sweep the group either way.
+ killProcessTree(child, "SIGKILL");
+ if (!rootExited && !(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`
@@ -1231,7 +1272,7 @@ export class CopilotClient {
// Force kill CLI process (only if we spawned it)
if (this.cliProcess && !this.isExternalServer) {
try {
- this.cliProcess.kill("SIGKILL");
+ killProcessTree(this.cliProcess, "SIGKILL");
} catch {
// Ignore errors
}
@@ -2510,6 +2551,10 @@ export class CopilotClient {
: ["ignore", "pipe", "pipe"];
// For .js files, spawn node explicitly; for executables, spawn directly
+ // Place the runtime in its own process group so killProcessTree()
+ // can signal all descendants atomically. On Windows detached has
+ // no effect — taskkill /T handles tree termination instead.
+ const detached = process.platform !== "win32";
const isJsFile = this.resolvedCliPath.endsWith(".js");
if (isJsFile) {
this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], {
@@ -2517,6 +2562,7 @@ export class CopilotClient {
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
+ detached,
});
} else {
this.cliProcess = spawn(this.resolvedCliPath, args, {
@@ -2524,8 +2570,14 @@ export class CopilotClient {
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
+ detached,
});
}
+ // Prevent the detached child from keeping the parent's event loop
+ // alive when the embedder exits without calling stop().
+ if (detached) {
+ this.cliProcess.unref();
+ }
let stdout = "";
let resolved = false;
diff --git a/nodejs/test/process_tree_kill.test.ts b/nodejs/test/process_tree_kill.test.ts
new file mode 100644
index 000000000..0ae791bf9
--- /dev/null
+++ b/nodejs/test/process_tree_kill.test.ts
@@ -0,0 +1,168 @@
+/**
+ * Tests for process-tree termination on stop()/forceStop().
+ *
+ * Each case spawns a real process tree (a runtime stand-in that forks a
+ * long-lived grandchild), hands it to a CopilotClient as its owned runtime,
+ * and drives the public teardown methods. The assertions are on the SDK's
+ * behaviour, so removing the tree termination fails these tests.
+ *
+ * @see https://github.com/github/copilot-sdk/issues/1804
+ */
+import { describe, expect, it } from "vitest";
+import { spawn, type ChildProcess } from "node:child_process";
+import { platform } from "node:os";
+import { CopilotClient, RuntimeConnection } from "../src/index.js";
+
+const isWindows = platform() === "win32";
+
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function sleep(ms: number): Promise