Use terminateWithTreeKill for ACP agent child process cleanup (#1460)

* fix(server): use terminateWithTreeKill for ACP agent child process cleanup

ACPAgentSession.close() was using child.kill("SIGTERM") which only
signals the direct child process (e.g. npx), leaving grandchild
processes (sh -c, node qodercli) alive as orphans. Over time these
accumulate and consume significant memory (~330MB each).

Replace all direct child.kill("SIGTERM") calls in ACP agent lifecycle
with terminateWithTreeKill(), which recursively walks the process tree
via /proc on Linux and taskkill /T on Windows. This aligns ACP with
OpenCode, Pi, and Codex providers which already use tree-kill.

Affected code paths:
- ACPAgentSession.close() — main child process
- ACPAgentSession.close() — terminal child processes
- ACPAgentSession.killTerminal()
- ACPAgentSession.releaseTerminal()
- terminateChildProcess() helper (used by closeProbe and init failure)

Removes the now-unused waitForChildExit() helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(server): address acp tree kill review

* test(server): cover acp release terminal tree kill

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yeshan333
2026-06-14 23:55:27 +08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0d76654ba0
commit a6ec352f07
2 changed files with 238 additions and 29 deletions
@@ -1,4 +1,4 @@
import { type ChildProcess } from "node:child_process"; import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { import {
@@ -28,6 +28,7 @@ import {
resolveACPModelSelection, resolveACPModelSelection,
summarizeACPRequestError, summarizeACPRequestError,
} from "./acp-agent.js"; } from "./acp-agent.js";
import * as treeKillModule from "../../../utils/tree-kill.js";
import { import {
COPILOT_ALLOW_ALL_MODE_ID, COPILOT_ALLOW_ALL_MODE_ID,
COPILOT_MODES, COPILOT_MODES,
@@ -144,6 +145,35 @@ function createTerminalChildStub(): ChildProcess {
return child; return child;
} }
function createProbeChildStub(order: string[]): ChildProcessWithoutNullStreams {
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
child.stdin = {
destroy: vi.fn(() => {
order.push("stdin.destroy");
}),
} as ChildProcessWithoutNullStreams["stdin"];
child.stdout = {
destroy: vi.fn(() => {
order.push("stdout.destroy");
}),
} as ChildProcessWithoutNullStreams["stdout"];
child.stderr = {
destroy: vi.fn(() => {
order.push("stderr.destroy");
}),
} as ChildProcessWithoutNullStreams["stderr"];
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
return child;
}
function createDeferredTermination(): { promise: Promise<"terminated">; resolve: () => void } {
let resolveTermination: () => void = () => {};
const promise = new Promise<"terminated">((resolve) => {
resolveTermination = () => resolve("terminated");
});
return { promise, resolve: resolveTermination };
}
function selectConfigOption( function selectConfigOption(
category: "mode" | "model" | "thought_level", category: "mode" | "model" | "thought_level",
values: string[], values: string[],
@@ -1974,3 +2004,191 @@ describe("ACPAgentSession", () => {
}); });
}); });
}); });
interface ACPCloseInternals {
child: ChildProcess | null;
closed: boolean;
connection: unknown;
sessionId: string | null;
terminalEntries: Map<string, { child: ChildProcess; exit: unknown }>;
subscribers: Map<unknown, unknown>;
activeForegroundTurnId: string | null;
}
describe("ACPAgentSession close() tree-kill", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("close() uses terminateWithTreeKill for the main child process", async () => {
const terminateWithTreeKill = vi
.spyOn(treeKillModule, "terminateWithTreeKill")
.mockResolvedValue("terminated");
const session = createSession();
const internals = asInternals<ACPCloseInternals>(session);
const child = createTerminalChildStub();
internals.child = child;
internals.connection = null;
internals.sessionId = null;
await session.close();
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
expect(child.kill).not.toHaveBeenCalled();
});
test("close() uses terminateWithTreeKill for terminal child processes", async () => {
const terminateWithTreeKill = vi
.spyOn(treeKillModule, "terminateWithTreeKill")
.mockResolvedValue("terminated");
const session = createSession();
const internals = asInternals<ACPCloseInternals>(session);
const terminalChild = createTerminalChildStub();
internals.terminalEntries = new Map([["terminal-1", { child: terminalChild, exit: null }]]);
internals.child = null;
internals.connection = null;
internals.sessionId = null;
await session.close();
expect(terminateWithTreeKill).toHaveBeenCalledWith(terminalChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
expect(terminalChild.kill).not.toHaveBeenCalled();
});
test("close() terminates terminal child processes in parallel", async () => {
const resolvers: Array<() => void> = [];
const terminateWithTreeKill = vi
.spyOn(treeKillModule, "terminateWithTreeKill")
.mockImplementation(() => {
const termination = createDeferredTermination();
resolvers.push(termination.resolve);
return termination.promise;
});
const session = createSession();
const internals = asInternals<ACPCloseInternals>(session);
const firstTerminalChild = createTerminalChildStub();
const secondTerminalChild = createTerminalChildStub();
internals.terminalEntries = new Map([
["terminal-1", { child: firstTerminalChild, exit: null }],
["terminal-2", { child: secondTerminalChild, exit: null }],
]);
internals.child = null;
internals.connection = null;
internals.sessionId = null;
const close = session.close();
await Promise.resolve();
expect(terminateWithTreeKill).toHaveBeenCalledTimes(2);
for (const resolve of resolvers) {
resolve();
}
await close;
});
test("killTerminal uses terminateWithTreeKill instead of direct SIGTERM", async () => {
const terminateWithTreeKill = vi
.spyOn(treeKillModule, "terminateWithTreeKill")
.mockResolvedValue("terminated");
const session = createSession();
const internals = asInternals<ACPCloseInternals>(session);
const child = createTerminalChildStub();
internals.terminalEntries = new Map([["terminal-1", { child, exit: null }]]);
await session.killTerminal({ sessionId: "session-1", terminalId: "terminal-1" });
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
expect(child.kill).not.toHaveBeenCalled();
});
test("releaseTerminal uses terminateWithTreeKill before removing a running terminal", async () => {
const terminateWithTreeKill = vi
.spyOn(treeKillModule, "terminateWithTreeKill")
.mockResolvedValue("terminated");
const child = createTerminalChildStub();
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: "sleep",
args: ["60"],
});
await session.releaseTerminal({
sessionId: "session-1",
terminalId: terminal.terminalId,
});
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
expect(child.kill).not.toHaveBeenCalled();
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).rejects.toThrow(`Unknown terminal '${terminal.terminalId}'`);
});
});
describe("ACPAgentClient probe cleanup", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("signals the probe process tree before destroying stdio", async () => {
const order: string[] = [];
const terminateWithTreeKill = vi
.spyOn(treeKillModule, "terminateWithTreeKill")
.mockImplementation(async () => {
order.push("tree-kill");
return "terminated";
});
const child = createProbeChildStub(order);
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
child,
connection: {
newSession: vi.fn().mockResolvedValue({
modes: null,
models: null,
configOptions: [],
}),
},
initialize: { agentCapabilities: {} },
} as SpawnedACPProcess;
}
}
const client = new TestACPAgentClient({
provider: "claude-acp",
logger: createTestLogger(),
defaultCommand: ["claude", "--acp"],
defaultModes: [],
});
await client.listModels({ cwd: "/tmp/acp-models", force: false });
expect(terminateWithTreeKill).toHaveBeenCalledWith(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
expect(order).toEqual(["tree-kill", "stdin.destroy", "stdout.destroy", "stderr.destroy"]);
});
});
@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Readable, Writable } from "node:stream"; import { Readable, Writable } from "node:stream";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import type { import type {
ReadableStream as NodeReadableStream, ReadableStream as NodeReadableStream,
WritableStream as NodeWritableStream, WritableStream as NodeWritableStream,
@@ -1664,14 +1666,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
} }
for (const terminal of this.terminalEntries.values()) { const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) =>
terminal.child.kill("SIGTERM"); terminateWithTreeKill(terminal.child, {
} gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}),
);
await Promise.all(terminalTerminations);
this.terminalEntries.clear(); this.terminalEntries.clear();
if (this.child) { if (this.child) {
this.child.kill("SIGTERM"); await terminateWithTreeKill(this.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
await waitForChildExit(this.child, 2_000);
} }
this.subscribers.clear(); this.subscribers.clear();
@@ -1853,7 +1858,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise<void> { async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
const entry = this.getTerminalEntry(params.terminalId); const entry = this.getTerminalEntry(params.terminalId);
if (!entry.exit) { if (!entry.exit) {
entry.child.kill("SIGTERM"); await terminateWithTreeKill(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
} }
this.terminalEntries.delete(params.terminalId); this.terminalEntries.delete(params.terminalId);
} }
@@ -1861,7 +1866,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async killTerminal(params: KillTerminalRequest): Promise<Record<string, never>> { async killTerminal(params: KillTerminalRequest): Promise<Record<string, never>> {
const entry = this.getTerminalEntry(params.terminalId); const entry = this.getTerminalEntry(params.terminalId);
if (!entry.exit) { if (!entry.exit) {
entry.child.kill("SIGTERM"); await terminateWithTreeKill(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
} }
return {}; return {};
} }
@@ -2884,29 +2889,15 @@ function coerceSessionConfigMetadata(
return metadata as Partial<AgentSessionConfig>; return metadata as Partial<AgentSessionConfig>;
} }
async function waitForChildExit(
child: ChildProcessWithoutNullStreams,
timeoutMs: number,
): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
await Promise.race([
new Promise<void>((resolve) => child.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}
async function terminateChildProcess( async function terminateChildProcess(
child: ChildProcessWithoutNullStreams, child: ChildProcessWithoutNullStreams,
timeoutMs: number, timeoutMs: number,
): Promise<void> { ): Promise<void> {
child.kill("SIGTERM"); try {
child.stdin.destroy(); await terminateWithTreeKill(child, { gracefulTimeoutMs: timeoutMs, forceTimeoutMs: timeoutMs });
child.stdout.destroy(); } finally {
child.stderr.destroy(); child.stdin.destroy();
await waitForChildExit(child, timeoutMs); child.stdout.destroy();
child.stderr.destroy();
}
} }