From 7979ffe80573677509fd8c52b53d9111806577de Mon Sep 17 00:00:00 2001 From: luvs01 Date: Thu, 10 Sep 2026 11:33:05 +0900 Subject: [PATCH] fix(adapters): terminate Windows coding-agent trees --- src/adapters/coding-agent/turn.ts | 24 +++++++++++++-- tests/providers/codebuddy-adapter.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index c1ff73fa8d..a264ce188f 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -1,4 +1,4 @@ -import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; import { commandInvocation } from "../../lib/win-exec"; import type { IncomingMeta } from "../base"; @@ -8,6 +8,9 @@ import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProv /** Injectable spawn for tests; production uses node:child_process. */ export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; +/** Injectable Windows process-tree terminator; production uses taskkill /T /F. */ +export type KillWindowsProcessTreeFn = (pid: number) => void; + /** Per-turn injectables: spawn/which seams for tests plus wall-clock ceilings for timeout, kill grace, and bounded reap. */ export interface CodingAgentDeps { spawn?: SpawnFn; @@ -20,6 +23,8 @@ export interface CodingAgentDeps { reapTimeoutMs?: number; /** Test seam for Windows command-shim invocation. */ platform?: NodeJS.Platform; + /** Test seam for terminating a Windows CLI and all descendants. */ + killWindowsProcessTree?: KillWindowsProcessTreeFn; } const DEFAULT_TIMEOUT_MS = 300_000; @@ -27,6 +32,14 @@ const DEFAULT_KILL_GRACE_MS = 2_000; /** Bound captured stderr so an error message can never carry an unbounded (or secret) payload. */ const MAX_STDERR_BYTES = 8 * 1024; +function killWindowsProcessTree(pid: number): void { + const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; + execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { + stdio: "pipe", + windowsHide: true, + }); +} + /** Env keys a CLI needs to run; everything else is dropped so the child env is scoped and deterministic. */ const INHERITED_ENV_KEYS = [ "PATH", "HOME", "USERPROFILE", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", @@ -93,6 +106,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { if (killed || child.killed) return; killed = true; + if (platform === "win32" && child.pid !== undefined) { + try { + (deps.killWindowsProcessTree ?? killWindowsProcessTree)(child.pid); + return; + } catch { /* fall back to terminating the direct child */ } + } try { child.kill("SIGTERM"); } catch { /* already gone */ } killTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index 0da898ea82..dfd97a8fa5 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -14,6 +14,7 @@ const enc = new TextEncoder(); beforeEach(() => clearCodeBuddyBinaryCache()); interface FakeChild extends EventEmitter { + pid?: number; stdout: Readable; stderr: Readable; stdin: Writable; @@ -346,6 +347,41 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(events.some(e => e.type === "done")).toBe(false); }); + test("a Windows abort terminates the cmd shim process tree", async () => { + const controller = new AbortController(); + const stdoutStream = new Readable({ + read() { setTimeout(() => controller.abort(), 5); }, + }); + const child = new EventEmitter() as FakeChild; + child.pid = 4242; + child.stdout = stdoutStream; + child.stderr = Readable.from([]); + child.written = []; + child.stdin = new Writable({ write(_c, _e, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + const directSignals: string[] = []; + child.kill = signal => { directSignals.push(signal ?? "SIGTERM"); return true; }; + const killedTrees: number[] = []; + + const adapter = createCodeBuddyAdapter(provider(), { + platform: "win32", + spawn: () => child as unknown as ChildProcess, + which: () => "C:\\npm\\codebuddy.cmd", + killWindowsProcessTree: pid => { + killedTrees.push(pid); + child.exitCode = 1; + child.emit("close", 1); + }, + killGraceMs: 20, + }); + const events = await run(adapter, parsed(), incoming(controller.signal)); + + expect(killedTrees).toEqual([4242]); + expect(directSignals).toEqual([]); + expect(events).toContainEqual(expect.objectContaining({ type: "error", retryable: false })); + }); + test("a timeout destroys a stalled stdout stream and returns even when close never arrives", async () => { const stdoutStream = new Readable({ read() { /* stays open until timeout destroys it */ } }); const child = new EventEmitter() as FakeChild;