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:
co-authored by
Claude Opus 4.6
parent
0d76654ba0
commit
a6ec352f07
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user