Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions go/process_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
13 changes: 13 additions & 0 deletions go/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package copilot

import (
"fmt"
"os/exec"
"syscall"
)
Expand All @@ -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()
}
}
22 changes: 19 additions & 3 deletions java/src/main/java/com/github/copilot/CopilotClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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.
* <p>
Expand Down
51 changes: 48 additions & 3 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -2468,22 +2502,33 @@ 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], {
stdio: stdioConfig,
cwd: this.options.workingDirectory,
env: envWithoutNodeDebug,
windowsHide: true,
detached,
});
} else {
this.cliProcess = spawn(this.resolvedCliPath, args, {
stdio: stdioConfig,
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;
Expand Down
46 changes: 43 additions & 3 deletions python/copilot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down
Loading