Skip to content
Draft
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
6 changes: 2 additions & 4 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions go/process_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
9 changes: 7 additions & 2 deletions go/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
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
65 changes: 57 additions & 8 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);

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

return true;
} catch {
return child.kill(signal);
}
}

/**
* Convert tool parameters to JSON schema format for sending to CLI
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -2468,22 +2506,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
128 changes: 128 additions & 0 deletions nodejs/test/process_tree_kill.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Spawn a helper that starts a long-lived grandchild, both in a new
* process group (matching the SDK's spawn behavior).
*/
function spawnTreeHelper(): { parent: ChildProcess; getGrandchildPid: () => Promise<number> } {
const helperScript = `
const { spawn } = require("child_process");
const child = spawn(process.execPath, ["-e", "setTimeout(()=>{},120000)"], { stdio: "ignore" });
process.stdout.write(String(child.pid));
setTimeout(() => {}, 120000);
`;
const parent = spawn(process.execPath, ["-e", helperScript], {
stdio: ["ignore", "pipe", "ignore"],
detached: platform() !== "win32",
});
if (platform() !== "win32") {
parent.unref();
}

const getGrandchildPid = (): Promise<number> =>
new Promise((resolve, reject) => {
let data = "";
parent.stdout!.on("data", (chunk) => {
data += chunk.toString();
const pid = parseInt(data.trim(), 10);
if (!isNaN(pid) && pid > 0) resolve(pid);
});
parent.once("error", reject);
setTimeout(() => reject(new Error("Timeout waiting for grandchild PID")), 5000);
});

return { parent, getGrandchildPid };
}

describe("killProcessTree", () => {
it("should kill parent and grandchild on POSIX (process group)", async () => {
if (platform() === "win32") return; // Tested separately below

const { parent, getGrandchildPid } = spawnTreeHelper();
const grandchildPid = await getGrandchildPid();
const parentPid = parent.pid!;

// Both alive before kill
expect(isProcessAlive(parentPid)).toBe(true);
expect(isProcessAlive(grandchildPid)).toBe(true);

// Kill process group (same as SDK does)
try {
process.kill(-parentPid, "SIGKILL");
} catch {
parent.kill("SIGKILL");
}

await sleep(200);

expect(isProcessAlive(parentPid)).toBe(false);
expect(isProcessAlive(grandchildPid)).toBe(false);
});

it("should kill parent and grandchild on Windows (taskkill /T)", async () => {
if (platform() !== "win32") return; // Windows only

const { parent, getGrandchildPid } = spawnTreeHelper();
const grandchildPid = await getGrandchildPid();
const parentPid = parent.pid!;

// Both alive before kill
expect(isProcessAlive(parentPid)).toBe(true);
expect(isProcessAlive(grandchildPid)).toBe(true);

// Tree kill (same as SDK does)
try {
execSync(`taskkill /T /F /PID ${parentPid}`, { stdio: "ignore", timeout: 5000 });
} catch {
parent.kill();
}

await sleep(200);

expect(isProcessAlive(parentPid)).toBe(false);
expect(isProcessAlive(grandchildPid)).toBe(false);
});
});

describe("CopilotClient external/in-process modes", () => {
it("should not attempt tree termination for external-server connections", async () => {
const { CopilotClient, RuntimeConnection } = await import("../src/index.js");
const client = new CopilotClient({
connection: RuntimeConnection.forUri("http://localhost:19999"),
});
// isExternalServer is true for URI connections — stop() won't kill
expect((client as any).isExternalServer).toBe(true);
// stop() should complete without error (no process to kill)
const errors = await client.stop();
expect(errors).toHaveLength(0);
});
});
Loading