|
11 | 11 | * @module client |
12 | 12 | */ |
13 | 13 |
|
14 | | -import { spawn, type ChildProcess } from "node:child_process"; |
| 14 | +import { spawn, execSync, type ChildProcess } from "node:child_process"; |
15 | 15 | import { randomUUID } from "node:crypto"; |
16 | 16 | import { existsSync } from "node:fs"; |
17 | 17 | import { createRequire } from "node:module"; |
@@ -153,6 +153,40 @@ async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise |
153 | 153 | }); |
154 | 154 | } |
155 | 155 |
|
| 156 | +/** |
| 157 | + * Terminate the runtime's process tree. |
| 158 | + * |
| 159 | + * - Windows: `taskkill /T /F` kills the entire tree rooted at `pid`. |
| 160 | + * - POSIX: the runtime is spawned in its own process group (`detached: true`), |
| 161 | + * so `kill(-pid)` signals every process in that group. |
| 162 | + * |
| 163 | + * Falls back to `child.kill(signal)` if the tree-wide signal fails (e.g. the |
| 164 | + * process already exited). |
| 165 | + * |
| 166 | + * @see https://github.com/github/copilot-sdk/issues/1804 |
| 167 | + */ |
| 168 | +function killProcessTree(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): boolean { |
| 169 | + const pid = child.pid; |
| 170 | + if (pid == null) { |
| 171 | + return false; |
| 172 | + } |
| 173 | + if (process.platform === "win32") { |
| 174 | + try { |
| 175 | + execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 }); |
| 176 | + return true; |
| 177 | + } catch { |
| 178 | + return child.kill(signal); |
| 179 | + } |
| 180 | + } |
| 181 | + // POSIX: signal the process group (negative PID). |
| 182 | + try { |
| 183 | + process.kill(-pid, signal); |
| 184 | + return true; |
| 185 | + } catch { |
| 186 | + return child.kill(signal); |
| 187 | + } |
| 188 | +} |
| 189 | + |
156 | 190 | /** |
157 | 191 | * Convert tool parameters to JSON schema format for sending to CLI |
158 | 192 | */ |
@@ -1082,13 +1116,17 @@ export class CopilotClient { |
1082 | 1116 | this.cliProcess = null; |
1083 | 1117 | try { |
1084 | 1118 | if (child.exitCode == null && child.signalCode == null) { |
1085 | | - child.kill(); |
| 1119 | + killProcessTree(child, "SIGTERM"); |
1086 | 1120 | if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) { |
1087 | | - errors.push( |
1088 | | - new Error( |
1089 | | - `Timed out waiting for CLI process to exit after kill: ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms` |
1090 | | - ) |
1091 | | - ); |
| 1121 | + // SIGTERM-resistant descendants may survive; escalate to SIGKILL. |
| 1122 | + killProcessTree(child, "SIGKILL"); |
| 1123 | + if (!(await waitForChildExit(child, RUNTIME_SHUTDOWN_TIMEOUT_MS))) { |
| 1124 | + errors.push( |
| 1125 | + new Error( |
| 1126 | + `Timed out waiting for CLI process to exit after kill: ${RUNTIME_SHUTDOWN_TIMEOUT_MS}ms` |
| 1127 | + ) |
| 1128 | + ); |
| 1129 | + } |
1092 | 1130 | } |
1093 | 1131 | } |
1094 | 1132 | } catch (error) { |
@@ -1209,7 +1247,7 @@ export class CopilotClient { |
1209 | 1247 | // Force kill CLI process (only if we spawned it) |
1210 | 1248 | if (this.cliProcess && !this.isExternalServer) { |
1211 | 1249 | try { |
1212 | | - this.cliProcess.kill("SIGKILL"); |
| 1250 | + killProcessTree(this.cliProcess, "SIGKILL"); |
1213 | 1251 | } catch { |
1214 | 1252 | // Ignore errors |
1215 | 1253 | } |
@@ -2468,22 +2506,33 @@ export class CopilotClient { |
2468 | 2506 | : ["ignore", "pipe", "pipe"]; |
2469 | 2507 |
|
2470 | 2508 | // For .js files, spawn node explicitly; for executables, spawn directly |
| 2509 | + // Place the runtime in its own process group so killProcessTree() |
| 2510 | + // can signal all descendants atomically. On Windows detached has |
| 2511 | + // no effect — taskkill /T handles tree termination instead. |
| 2512 | + const detached = process.platform !== "win32"; |
2471 | 2513 | const isJsFile = this.resolvedCliPath.endsWith(".js"); |
2472 | 2514 | if (isJsFile) { |
2473 | 2515 | this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], { |
2474 | 2516 | stdio: stdioConfig, |
2475 | 2517 | cwd: this.options.workingDirectory, |
2476 | 2518 | env: envWithoutNodeDebug, |
2477 | 2519 | windowsHide: true, |
| 2520 | + detached, |
2478 | 2521 | }); |
2479 | 2522 | } else { |
2480 | 2523 | this.cliProcess = spawn(this.resolvedCliPath, args, { |
2481 | 2524 | stdio: stdioConfig, |
2482 | 2525 | cwd: this.options.workingDirectory, |
2483 | 2526 | env: envWithoutNodeDebug, |
2484 | 2527 | windowsHide: true, |
| 2528 | + detached, |
2485 | 2529 | }); |
2486 | 2530 | } |
| 2531 | + // Prevent the detached child from keeping the parent's event loop |
| 2532 | + // alive when the embedder exits without calling stop(). |
| 2533 | + if (detached) { |
| 2534 | + this.cliProcess.unref(); |
| 2535 | + } |
2487 | 2536 |
|
2488 | 2537 | let stdout = ""; |
2489 | 2538 | let resolved = false; |
|
0 commit comments