diff --git a/go/client.go b/go/client.go index 292a5729e5..50c548787c 100644 --- a/go/client.go +++ b/go/client.go @@ -634,7 +634,7 @@ 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() + killProcessTreeByPid(p.Pid) } // Clear sessions immediately without trying to destroy them @@ -2189,9 +2189,7 @@ 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) - } + killProcessTreeByPid(p.Pid) } c.process = nil return nil diff --git a/go/process_other.go b/go/process_other.go index 5b3ba6353a..5cac984f5f 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) { + _ = syscall.Kill(-pid, syscall.SIGKILL) } diff --git a/go/process_windows.go b/go/process_windows.go index 37f954fca0..65cae74c04 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) { + _ = exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprintf("%d", pid)).Run() +} diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 44878b87ec..aab5e3381b 100644 --- a/java/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/src/main/java/com/github/copilot/CopilotClient.java @@ -480,19 +480,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); if (!process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { LOG.fine("Process did not terminate within force kill timeout"); } return; } - process.destroy(); + killProcessTree(process); if (process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { return; } - process.destroyForcibly(); + killProcessTree(process); if (!process.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { LOG.fine("Process did not terminate within force kill timeout"); } @@ -505,6 +505,22 @@ private static void cleanupCliProcess(Process process, boolean forceImmediately) } } + /** + * Terminate the runtime's process tree: snapshot all descendants, destroy + * them, then destroy the root. Uses {@link ProcessHandle#descendants()} + * which works cross-platform (Windows, Linux, macOS). + */ + private static void killProcessTree(Process process) { + try { + process.toHandle().descendants().forEach(ph -> { + try { ph.destroyForcibly(); } catch (Exception ignored) {} + }); + } catch (Exception e) { + LOG.log(Level.FINE, "Error killing process descendants", e); + } + process.destroyForcibly(); + } + /** * Creates a new Copilot session with the specified configuration. *
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index 78290bf689..350d37da3e 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,40 @@ 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`.
+ * - POSIX: the runtime is spawned in its own process group (`detached: true`),
+ * so `kill(-pid)` signals every process in that group.
+ *
+ * Falls back to `child.kill(signal)` if the tree-wide signal fails (e.g. the
+ * process already exited).
+ *
+ * @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 false;
+ }
+ 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
*/
@@ -1082,13 +1116,17 @@ export class CopilotClient {
this.cliProcess = null;
try {
if (child.exitCode == null && child.signalCode == null) {
- child.kill();
+ killProcessTree(child, "SIGTERM");
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");
+ 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`
+ )
+ );
+ }
}
}
} catch (error) {
@@ -1209,7 +1247,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
}
@@ -2468,6 +2506,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], {
@@ -2475,6 +2517,7 @@ export class CopilotClient {
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
+ detached,
});
} else {
this.cliProcess = spawn(this.resolvedCliPath, args, {
@@ -2482,8 +2525,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 0000000000..01fce6c8f3
--- /dev/null
+++ b/nodejs/test/process_tree_kill.test.ts
@@ -0,0 +1,128 @@
+/**
+ * Tests for process-tree termination on stop()/forceStop().
+ *
+ * Spawns a helper process that starts a long-lived child, then verifies
+ * both are terminated by killProcessTree(). Also verifies that external-server
+ * and in-process modes do not enter tree termination.
+ *
+ * @see https://github.com/github/copilot-sdk/issues/1804
+ */
+import { describe, expect, it } from "vitest";
+import { spawn, execSync, type ChildProcess } from "node:child_process";
+import { resolve } from "node:path";
+import { platform } from "node:os";
+
+// Import the private killProcessTree via dynamic require workaround:
+// We test the exported behavior indirectly through CopilotClient, but for
+// focused process-tree tests we spawn directly with detached:true and verify.
+
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function sleep(ms: number): Promise