// Kill tree tests cover process tree termination and platform-specific fallbacks. 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(() => ({ spawnMock: vi.fn(), })); vi.mock("node:child_process", async () => { const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks"); return mockNodeBuiltinModule( () => vi.importActual("node:child_process"), { spawn: (...args: unknown[]) => spawnMock(...args), }, ); }); let killProcessTree: typeof import("./kill-tree.js").killProcessTree; let signalProcessTree: typeof import("./kill-tree.js").signalProcessTree; function expectTaskkillCall(index: number, args: string[]) { expect(spawnMock.mock.calls[index]).toStrictEqual([ "taskkill", args, { detached: true, stdio: "ignore", windowsHide: true, }, ]); } describe("killProcessTree", () => { let killSpy: ReturnType; beforeAll(async () => { ({ killProcessTree, signalProcessTree } = await import("./kill-tree.js")); }); beforeEach(() => { spawnMock.mockClear(); killSpy = vi.spyOn(process, "kill"); vi.useFakeTimers(); }); afterEach(() => { killSpy.mockRestore(); vi.useRealTimers(); vi.clearAllMocks(); }); it("on Windows skips delayed force-kill when PID is already gone", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === 4242 && signal === 0) { throw new Error("ESRCH"); } return true; }) as typeof process.kill); await withMockedPlatform("win32", async () => { killProcessTree(4242, { graceMs: 25 }); expect(spawnMock).toHaveBeenCalledTimes(1); expectTaskkillCall(0, ["/T", "/PID", "4242"]); await vi.advanceTimersByTimeAsync(25); expect(spawnMock).toHaveBeenCalledTimes(1); }); }); it("on Windows force-kills after grace period only when PID still exists", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === 5252 && signal === 0) { return true; } return true; }) as typeof process.kill); await withMockedPlatform("win32", async () => { killProcessTree(5252, { graceMs: 10 }); await vi.advanceTimersByTimeAsync(10); expect(spawnMock).toHaveBeenCalledTimes(2); expectTaskkillCall(0, ["/T", "/PID", "5252"]); expectTaskkillCall(1, ["/F", "/T", "/PID", "5252"]); }); }); it("on Unix sends SIGTERM first and skips SIGKILL when process exits", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === -3333 && signal === 0) { throw new Error("ESRCH"); } if (pid === 3333 && signal === 0) { throw new Error("ESRCH"); } return true; }) as typeof process.kill); await withMockedPlatform("linux", async () => { killProcessTree(3333, { graceMs: 10 }); await vi.advanceTimersByTimeAsync(10); expect(killSpy).toHaveBeenCalledWith(-3333, "SIGTERM"); expect(killSpy).not.toHaveBeenCalledWith(-3333, "SIGKILL"); expect(killSpy).not.toHaveBeenCalledWith(3333, "SIGKILL"); }); }); it("on Unix sends SIGKILL after grace period when process is still alive", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === -4444 && signal === 0) { return true; } return true; }) as typeof process.kill); await withMockedPlatform("linux", async () => { killProcessTree(4444, { graceMs: 5 }); await vi.advanceTimersByTimeAsync(5); expect(killSpy).toHaveBeenCalledWith(-4444, "SIGTERM"); expect(killSpy).toHaveBeenCalledWith(-4444, "SIGKILL"); }); }); it("on Unix force-kills synchronously without SIGTERM or delayed escalation", async () => { killSpy.mockImplementation(() => true); await withMockedPlatform("linux", async () => { killProcessTree(4949, { force: true }); await vi.advanceTimersByTimeAsync(60_000); expect(killSpy).toHaveBeenCalledTimes(1); expect(killSpy).toHaveBeenCalledWith(-4949, "SIGKILL"); expect(killSpy).not.toHaveBeenCalledWith(-4949, "SIGTERM"); }); }); it("on Unix force-kills a live detached group even after the parent pid exits", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === -4545 && signal === 0) { return true; } if (pid === 4545 && signal === 0) { throw new Error("ESRCH"); } return true; }) as typeof process.kill); await withMockedPlatform("linux", async () => { killProcessTree(4545, { graceMs: 5 }); await vi.advanceTimersByTimeAsync(5); expect(killSpy).toHaveBeenCalledWith(-4545, "SIGTERM"); expect(killSpy).toHaveBeenCalledWith(-4545, "SIGKILL"); expect(killSpy).not.toHaveBeenCalledWith(4545, "SIGKILL"); }); }); it("on Unix skips group kill when detached:false to avoid SIGTERMing the parent's own process group (#71662)", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === 5555 && signal === 0) { throw new Error("ESRCH"); } return true; }) as typeof process.kill); await withMockedPlatform("linux", async () => { killProcessTree(5555, { graceMs: 10, detached: false }); await vi.advanceTimersByTimeAsync(10); // Direct pid kill is fine. Group kill (`-pid`) is FORBIDDEN here because // when the child wasn't spawned detached, its process group is the // gateway's group — `-pid` would SIGTERM the gateway itself. expect(killSpy).toHaveBeenCalledWith(5555, "SIGTERM"); expect(killSpy).not.toHaveBeenCalledWith(-5555, "SIGTERM"); expect(killSpy).not.toHaveBeenCalledWith(-5555, "SIGKILL"); }); }); it("on Unix uses group kill by default (detached:true preserved as the existing behavior)", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === -6666 && signal === 0) { throw new Error("ESRCH"); } if (pid === 6666 && signal === 0) { throw new Error("ESRCH"); } return true; }) as typeof process.kill); await withMockedPlatform("linux", async () => { killProcessTree(6666, { graceMs: 10 }); await vi.advanceTimersByTimeAsync(10); expect(killSpy).toHaveBeenCalledWith(-6666, "SIGTERM"); }); }); it("on Unix sends a single requested tree signal without scheduling escalation", async () => { killSpy.mockImplementation(() => true); await withMockedPlatform("linux", async () => { signalProcessTree(7777, "SIGTERM"); await vi.advanceTimersByTimeAsync(60_000); expect(killSpy).toHaveBeenCalledTimes(1); expect(killSpy).toHaveBeenCalledWith(-7777, "SIGTERM"); expect(killSpy).not.toHaveBeenCalledWith(-7777, "SIGKILL"); }); }); it("on Windows maps requested tree signals to taskkill force mode", async () => { await withMockedPlatform("win32", async () => { signalProcessTree(8888, "SIGTERM"); signalProcessTree(8888, "SIGKILL"); expect(spawnMock).toHaveBeenCalledTimes(2); expectTaskkillCall(0, ["/T", "/PID", "8888"]); expectTaskkillCall(1, ["/F", "/T", "/PID", "8888"]); }); }); it("on Windows force-kills synchronously without delayed taskkill", async () => { await withMockedPlatform("win32", async () => { killProcessTree(9999, { force: true }); await vi.advanceTimersByTimeAsync(60_000); expect(spawnMock).toHaveBeenCalledTimes(1); expectTaskkillCall(0, ["/F", "/T", "/PID", "9999"]); }); }); it("on Windows ignores async taskkill spawn errors", async () => { const taskkillChild = new EventEmitter(); spawnMock.mockReturnValueOnce(taskkillChild); await withMockedPlatform("win32", async () => { killProcessTree(9191, { force: true }); expect(() => taskkillChild.emit("error", new Error("spawn ENOENT"))).not.toThrow(); expectTaskkillCall(0, ["/F", "/T", "/PID", "9191"]); }); }); });