mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user