fix(kill-tree): verify process group leader before using group kill to prevent gateway SIGTERM (#76259) (#94697)

* fix(kill-tree): verify process group leader before group kill to prevent gateway SIGTERM (#76259)

- Add isProcessGroupLeader() to killProcessTree/signalProcessTree: ps -p <pid> -o pgid= primary check with /proc/<pid>/stat fallback on Linux. Group kill only when the PID is its own process group leader; non-leaders fall back to single-pid kill, preventing accidental gateway SIGTERM when a non-detached child shares the gateway's process group.
- Propagate detached: true to all detached-spawn cleanup callers (exec-termination, agent-bundle LSP, mcp-stdio, bash, supervisor pty, agent-core nodejs) so detached group cleanup survives leader exit.
- Gateway/daemon cleanup paths (schtasks, restart-health) keep the leader-checked default (detached omitted).

Closes #76259

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

* refactor(process): tighten process-group ownership checks

* refactor(daemon): split restart diagnostics

* refactor(daemon): isolate restart health types

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
thomas.szbay
2026-07-17 03:30:51 +08:00
committed by GitHub
parent cf24f14c63
commit b06fe2a673
21 changed files with 299 additions and 146 deletions
+5 -2
View File
@@ -102,7 +102,10 @@ export function createCommandTerminationController(params: {
startWindowsTermination(childPid, true);
return true;
}
terminateProcessTree(childPid, { graceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS });
terminateProcessTree(childPid, {
graceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS,
detached: true,
});
return false;
}
if (!directChildAlive) {
@@ -136,7 +139,7 @@ export function createCommandTerminationController(params: {
});
}
if (process.platform !== "win32") {
terminateProcessTree(params.child.pid, { force: true });
terminateProcessTree(params.child.pid, { force: true, detached: true });
}
};
+90 -2
View File
@@ -3,8 +3,14 @@ import { EventEmitter } from "node:events";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { withMockedPlatform } from "../test-utils/vitest-spies.js";
const { spawnMock } = vi.hoisted(() => ({
const { readFileSyncMock, spawnMock, spawnSyncMock } = vi.hoisted(() => ({
readFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
spawnSyncMock: vi.fn(),
}));
vi.mock("node:fs", () => ({
readFileSync: (...args: unknown[]) => readFileSyncMock(...args),
}));
vi.mock("node:child_process", async () => {
@@ -13,6 +19,7 @@ vi.mock("node:child_process", async () => {
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{
spawn: (...args: unknown[]) => spawnMock(...args),
spawnSync: (...args: unknown[]) => spawnSyncMock(...args),
},
);
});
@@ -32,6 +39,18 @@ function expectTaskkillCall(index: number, args: string[]) {
]);
}
function mockIsProcessGroupLeader(...pids: number[]) {
spawnSyncMock.mockImplementation((command: string, args: string[]) => {
if (command === "ps" && args[0] === "-p" && args[2] === "-o" && args[3] === "pgid=") {
const pid = Number.parseInt(args[1] ?? "", 10);
if (pids.includes(pid)) {
return { status: 0, stdout: String(pid) };
}
}
return { status: 1, stdout: "" };
});
}
describe("killProcessTree", () => {
let killSpy: ReturnType<typeof vi.spyOn>;
@@ -40,7 +59,12 @@ describe("killProcessTree", () => {
});
beforeEach(() => {
readFileSyncMock.mockReset();
readFileSyncMock.mockImplementation(() => {
throw new Error("proc unavailable");
});
spawnMock.mockClear();
spawnSyncMock.mockClear();
killSpy = vi.spyOn(process, "kill");
vi.useFakeTimers();
});
@@ -101,6 +125,7 @@ describe("killProcessTree", () => {
}) as typeof process.kill);
await withMockedPlatform("linux", async () => {
mockIsProcessGroupLeader(3333);
killProcessTree(3333, { graceMs: 10 });
await vi.advanceTimersByTimeAsync(10);
@@ -120,6 +145,7 @@ describe("killProcessTree", () => {
}) as typeof process.kill);
await withMockedPlatform("linux", async () => {
mockIsProcessGroupLeader(4444);
killProcessTree(4444, { graceMs: 5 });
await vi.advanceTimersByTimeAsync(5);
@@ -133,6 +159,7 @@ describe("killProcessTree", () => {
killSpy.mockImplementation(() => true);
await withMockedPlatform("linux", async () => {
mockIsProcessGroupLeader(4949);
killProcessTree(4949, { force: true });
await vi.advanceTimersByTimeAsync(60_000);
@@ -154,6 +181,7 @@ describe("killProcessTree", () => {
}) as typeof process.kill);
await withMockedPlatform("linux", async () => {
mockIsProcessGroupLeader(4545);
killProcessTree(4545, { graceMs: 5 });
await vi.advanceTimersByTimeAsync(5);
@@ -185,7 +213,7 @@ describe("killProcessTree", () => {
});
});
it("on Unix uses group kill by default (detached:true preserved as the existing behavior)", async () => {
it("on Unix uses group kill when the omitted option resolves to a group leader", async () => {
killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => {
if (pid === -6666 && signal === 0) {
throw new Error("ESRCH");
@@ -197,6 +225,7 @@ describe("killProcessTree", () => {
}) as typeof process.kill);
await withMockedPlatform("linux", async () => {
mockIsProcessGroupLeader(6666);
killProcessTree(6666, { graceMs: 10 });
await vi.advanceTimersByTimeAsync(10);
@@ -204,10 +233,69 @@ describe("killProcessTree", () => {
});
});
it.each([
[
"throws",
() => {
throw new Error("ps ENOENT");
},
],
["exits non-zero", () => ({ status: 1, stdout: "" })],
["returns non-numeric output", () => ({ status: 0, stdout: "not-a-pgid" })],
["returns empty output", () => ({ status: 0, stdout: "" })],
])("on Unix falls back to single-pid kill when ps %s", async (_label, psResult) => {
killSpy.mockImplementation(() => true);
await withMockedPlatform("darwin", async () => {
spawnSyncMock.mockImplementation(psResult);
killProcessTree(8888, { graceMs: 10 });
await vi.advanceTimersByTimeAsync(10);
expect(killSpy).toHaveBeenCalledWith(8888, "SIGTERM");
expect(killSpy).not.toHaveBeenCalledWith(-8888, "SIGTERM");
expect(killSpy).not.toHaveBeenCalledWith(-8888, "SIGKILL");
});
});
it("on Unix falls back to single-pid kill when ps returns different PGID", async () => {
killSpy.mockImplementation(() => true);
await withMockedPlatform("linux", async () => {
spawnSyncMock.mockImplementation((command: string, args: string[]) => {
if (command === "ps" && args[0] === "-p" && args[2] === "-o" && args[3] === "pgid=") {
const pid = Number.parseInt(args[1] ?? "", 10);
if (pid === 9999) {
return { status: 0, stdout: "12345\n" };
}
}
return { status: 1, stdout: "" };
});
killProcessTree(9999, { graceMs: 10 });
await vi.advanceTimersByTimeAsync(10);
expect(killSpy).toHaveBeenCalledWith(9999, "SIGTERM");
expect(killSpy).not.toHaveBeenCalledWith(-9999, "SIGTERM");
expect(killSpy).not.toHaveBeenCalledWith(-9999, "SIGKILL");
});
});
it("on Linux reads process-group ownership from procfs without spawning ps", async () => {
killSpy.mockImplementation(() => true);
readFileSyncMock.mockReturnValue("7777 (shell worker) S 1 7777 7777 0");
await withMockedPlatform("linux", async () => {
signalProcessTree(7777, "SIGTERM");
expect(killSpy).toHaveBeenCalledWith(-7777, "SIGTERM");
expect(spawnSyncMock).not.toHaveBeenCalled();
});
});
it("on Unix sends a single requested tree signal without scheduling escalation", async () => {
killSpy.mockImplementation(() => true);
await withMockedPlatform("linux", async () => {
mockIsProcessGroupLeader(7777);
signalProcessTree(7777, "SIGTERM");
await vi.advanceTimersByTimeAsync(60_000);
+3 -3
View File
@@ -116,7 +116,7 @@ describe("createPtyAdapter", () => {
});
adapter.kill("SIGTERM");
expect(signalProcessTreeMock).toHaveBeenCalledWith(1234, "SIGTERM");
expect(signalProcessTreeMock).toHaveBeenCalledWith(1234, "SIGTERM", { detached: true });
expect(ptyKillMock).not.toHaveBeenCalled();
});
@@ -129,7 +129,7 @@ describe("createPtyAdapter", () => {
});
adapter.kill();
expect(signalProcessTreeMock).toHaveBeenCalledWith(1234, "SIGKILL");
expect(signalProcessTreeMock).toHaveBeenCalledWith(1234, "SIGKILL", { detached: true });
expect(ptyKillMock).not.toHaveBeenCalled();
});
@@ -319,7 +319,7 @@ describe("createPtyAdapter", () => {
});
adapter.kill("SIGKILL");
expect(signalProcessTreeMock).toHaveBeenCalledWith(4567, "SIGKILL");
expect(signalProcessTreeMock).toHaveBeenCalledWith(4567, "SIGKILL", { detached: true });
expect(ptyKillMock).not.toHaveBeenCalled();
} finally {
if (originalPlatform) {
+1 -1
View File
@@ -151,7 +151,7 @@ export async function createPtyAdapter(params: {
typeof pty.pid === "number" &&
pty.pid > 0
) {
signalProcessTree(pty.pid, signal);
signalProcessTree(pty.pid, signal, { detached: true });
} else if (process.platform === "win32") {
pty.kill();
} else {
+3 -1
View File
@@ -49,7 +49,9 @@ describe("terminal PTY teardown", () => {
it.each([undefined, "SIGTERM"] as const)("signals the process tree for %s", async (signal) => {
const { handle, pty } = await spawnFakePty();
handle.kill(signal);
expect(mocks.signalProcessTree).toHaveBeenCalledWith(4321, signal ?? "SIGKILL");
expect(mocks.signalProcessTree).toHaveBeenCalledWith(4321, signal ?? "SIGKILL", {
detached: true,
});
expect(pty.kill).not.toHaveBeenCalled();
});
+3 -1
View File
@@ -86,7 +86,9 @@ function killPtyTree(pty: Pick<IPty, "pid" | "kill">, signal?: string): void {
const sig = (signal ?? "SIGKILL") as NodeJS.Signals;
try {
if ((sig === "SIGKILL" || sig === "SIGTERM") && typeof pty.pid === "number" && pty.pid > 0) {
signalProcessTree(pty.pid, sig);
// forkpty creates a new session/process group; retain descendant cleanup
// after the shell exits and only its group remains.
signalProcessTree(pty.pid, sig, { detached: true });
} else if (process.platform === "win32") {
pty.kill();
} else {