diff --git a/go/client.go b/go/client.go index 292a5729e..a47d6b158 100644 --- a/go/client.go +++ b/go/client.go @@ -634,7 +634,11 @@ 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 c.process != nil { + killProcessTree(c.process) + } else { + p.Kill() + } } // Clear sessions immediately without trying to destroy them @@ -2188,10 +2192,9 @@ func (c *Client) killProcess() error { c.ffiHost.Dispose() 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 c.process != nil { + killProcessTree(c.process) + c.osProcess.Store(nil) } c.process = nil return nil diff --git a/go/process_other.go b/go/process_other.go index 5b3ba6353..6c8de2f6b 100644 --- a/go/process_other.go +++ b/go/process_other.go @@ -2,10 +2,25 @@ 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 +// killProcessTree can signal all descendants atomically. func configureProcAttr(cmd *exec.Cmd) { - // No special configuration needed on non-Windows platforms + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// killProcessTree signals the runtime's process group (negative PID). +// Falls back to killing the immediate process if the group signal fails. +func killProcessTree(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + // Signal the entire process group. + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + _ = cmd.Process.Kill() + } } diff --git a/go/process_windows.go b/go/process_windows.go index 37f954fca..07677c65b 100644 --- a/go/process_windows.go +++ b/go/process_windows.go @@ -3,6 +3,7 @@ package copilot import ( + "fmt" "os/exec" "syscall" ) @@ -14,3 +15,15 @@ func configureProcAttr(cmd *exec.Cmd) { HideWindow: true, } } + +// killProcessTree terminates the runtime's entire process tree using +// taskkill /T /F. Falls back to killing the immediate process. +func killProcessTree(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + kill := exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprintf("%d", cmd.Process.Pid)) + if err := kill.Run(); err != nil { + _ = cmd.Process.Kill() + } +} diff --git a/java/src/main/java/com/github/copilot/CopilotClient.java b/java/src/main/java/com/github/copilot/CopilotClient.java index 44878b87e..aab5e3381 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 78290bf68..d69eebb7e 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,7 +1116,7 @@ export class CopilotClient { this.cliProcess = null; try { if (child.exitCode == null && child.signalCode == null) { - child.kill(); + killProcessTree(child); if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) { errors.push( new Error( @@ -1209,7 +1243,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 +2502,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 +2513,7 @@ export class CopilotClient { cwd: this.options.workingDirectory, env: envWithoutNodeDebug, windowsHide: true, + detached, }); } else { this.cliProcess = spawn(this.resolvedCliPath, args, { @@ -2482,8 +2521,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/python/copilot/client.py b/python/copilot/client.py index f7f0a4eb2..e08a23843 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1200,6 +1200,42 @@ def _session_lifecycle_event_from_dict(data: dict) -> SessionLifecycleEvent: _CLI_PROCESS_EXIT_TIMEOUT_SECONDS = 5 +def _kill_process_tree(proc: subprocess.Popen[Any]) -> None: + """Terminate the runtime's process tree. + + Windows: ``taskkill /T /F`` kills the entire tree rooted at *pid*. + POSIX: the runtime is spawned with ``start_new_session=True``, so + ``os.killpg(pid, signal)`` signals every process in that group. + + Falls back to ``proc.kill()`` if the tree-wide signal fails. + + See: https://github.com/github/copilot-sdk/issues/1804 + """ + pid = proc.pid + if pid is None: + return + if sys.platform == "win32": + try: + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(pid)], + capture_output=True, + timeout=5, + ) + except Exception: + try: + proc.kill() + except Exception: + pass + else: + try: + os.killpg(pid, 9) # SIGKILL to the runtime's process group + except (ProcessLookupError, PermissionError, OSError): + try: + proc.kill() + except Exception: + pass + + def _get_or_download_cli(*, include_runtime_lib: bool = False) -> str | None: """Get the cached CLI binary, downloading if necessary. @@ -1910,14 +1946,14 @@ async def stop(self) -> None: poll = getattr(self._cli_process, "poll", None) is_running = poll is None or poll() is None if is_running: - self._cli_process.terminate() + _kill_process_tree(self._cli_process) try: await asyncio.to_thread( self._cli_process.wait, timeout=_CLI_PROCESS_EXIT_TIMEOUT_SECONDS, ) except subprocess.TimeoutExpired: - self._cli_process.kill() + _kill_process_tree(self._cli_process) try: await asyncio.to_thread( self._cli_process.wait, @@ -1976,7 +2012,7 @@ async def force_stop(self) -> None: if self._process is not None and self._process is not self._cli_process: self._process.terminate() if self._cli_process is not None: - self._cli_process.kill() + _kill_process_tree(self._cli_process) self._process = None self._cli_process = None except Exception: @@ -4027,6 +4063,9 @@ async def _start_cli_server(self) -> None: cwd=cwd, env=env, creationflags=creationflags, + # Place the runtime in its own process group so + # _kill_process_tree() can signal all descendants. + start_new_session=(sys.platform != "win32"), ) self._cli_process = self._process else: @@ -4040,6 +4079,7 @@ async def _start_cli_server(self) -> None: cwd=cwd, env=env, creationflags=creationflags, + start_new_session=(sys.platform != "win32"), ) self._cli_process = self._process log_timing( diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f998d7225..d9ef1a321 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1624,6 +1624,15 @@ impl Client { fn build_command(program: &Path, options: &ClientOptions, working_directory: &Path) -> Command { let mut command = Command::new(program); + + // Place the runtime in its own process group so kill_process_tree() + // can signal all descendants atomically on POSIX. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + for arg in &options.prefix_args { command.arg(arg); } @@ -2397,7 +2406,7 @@ impl Client { // response and never self-exits. Waiting for a self-exit // that will never come just wastes time, so terminate the // child immediately. - if let Err(e) = child.kill().await { + if let Err(e) = kill_process_tree(&mut child).await { errors.push(e.into()); } } @@ -2455,10 +2464,9 @@ impl Client { pub fn force_stop(&self) { let pid = self.pid(); info!(pid = ?pid, "force-stopping CLI process"); - if let Some(mut child) = self.inner.child.lock().take() - && let Err(e) = child.start_kill() - { - error!(pid = ?pid, error = %e, "failed to send kill signal"); + if let Some(mut child) = self.inner.child.lock().take() { + force_kill_process_tree(&mut child); + } } self.inner.rpc.force_close(); #[cfg(feature = "bundled-in-process")] @@ -2513,15 +2521,70 @@ impl Client { } } +/// Terminate the runtime's process tree (async version for `stop()`). +/// +/// POSIX: signals the runtime's process group (negative PID) via the `kill` command. +/// Windows: uses `taskkill /T /F`. +/// Falls back to `child.kill()` on failure. +async fn kill_process_tree(child: &mut Child) -> std::io::Result<()> { + #[cfg(unix)] + { + if let Some(pid) = child.id() { + // Signal the entire process group via the kill command. + // The runtime was spawned with process_group(0), so its PID == PGID. + let _ = std::process::Command::new("kill") + .args(["-9", &format!("-{}", pid)]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + return Ok(()); + } + } + #[cfg(windows)] + { + if let Some(pid) = child.id() { + let _ = std::process::Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + return Ok(()); + } + } + child.kill().await +} + +/// Synchronous tree kill for `force_stop()` and `Drop`. +fn force_kill_process_tree(child: &mut Child) { + #[cfg(unix)] + { + if let Some(pid) = child.id() { + let _ = std::process::Command::new("kill") + .args(["-9", &format!("-{}", pid)]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + return; + } + } + #[cfg(windows)] + { + if let Some(pid) = child.id() { + let _ = std::process::Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + return; + } + } + let _ = child.start_kill(); +} + impl Drop for ClientInner { fn drop(&mut self) { if let Some(ref mut child) = *self.child.lock() { - let pid = child.id(); - if let Err(e) = child.start_kill() { - error!(pid = ?pid, error = %e, "failed to kill CLI process on drop"); - } else { - info!(pid = ?pid, "kill signal sent for CLI process on drop"); - } + force_kill_process_tree(child); } #[cfg(feature = "bundled-in-process")] {