Use tree-kill for daemon shutdown (#788)
* Use tree-kill for daemon shutdown * fix: update lockfile signatures and Nix hash * Use tree-kill for daemon shutdown --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
7558bd7947
commit
80bdb4d45b
@@ -51,7 +51,7 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { terminateProcessTree } from "../../../utils/process-tree.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
@@ -787,7 +787,7 @@ class CodexAppServerClient {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await terminateProcessTree(this.child, {
|
||||
const result = await terminateWithTreeKill(this.child, {
|
||||
gracefulTimeoutMs: APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { terminateProcessTree } from "../../../utils/process-tree.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
@@ -975,7 +975,7 @@ export class OpenCodeServerManager {
|
||||
if (server.process.killed) {
|
||||
return;
|
||||
}
|
||||
const result = await terminateProcessTree(server.process, {
|
||||
const result = await terminateWithTreeKill(server.process, {
|
||||
gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { signalProcessTree, terminateProcessTree, type ProcessTreeTarget } from "./process-tree.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe("signalProcessTree", () => {
|
||||
test.each(["darwin", "linux"] as const)("signals the %s process group", (platform) => {
|
||||
const child = createChild({ pid: 1234 });
|
||||
const kill = vi.fn(() => true);
|
||||
|
||||
signalProcessTree(child, "SIGTERM", { platform, kill });
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(-1234, "SIGTERM");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to direct child signaling when POSIX process group signaling fails", () => {
|
||||
const child = createChild({ pid: 1234 });
|
||||
|
||||
signalProcessTree(child, "SIGTERM", {
|
||||
platform: "darwin",
|
||||
kill: vi.fn(() => {
|
||||
throw new Error("no process group");
|
||||
}),
|
||||
});
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
test("uses taskkill for Windows process trees", () => {
|
||||
const child = createChild({ pid: 1234 });
|
||||
const execFileMock = vi.fn((_file, _args, callback) => {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
signalProcessTree(child, "SIGTERM", { platform: "win32", execFile: execFileMock });
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
"taskkill",
|
||||
["/pid", "1234", "/T"],
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("forces Windows process tree cleanup for SIGKILL", () => {
|
||||
const child = createChild({ pid: 1234 });
|
||||
const execFileMock = vi.fn((_file, _args, callback) => {
|
||||
callback(null);
|
||||
});
|
||||
|
||||
signalProcessTree(child, "SIGKILL", { platform: "win32", execFile: execFileMock });
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
"taskkill",
|
||||
["/pid", "1234", "/T", "/F"],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to direct child signaling when taskkill cannot run", () => {
|
||||
const child = createChild({ pid: 1234 });
|
||||
const execFileMock = vi.fn((_file, _args, callback) => {
|
||||
callback(Object.assign(new Error("taskkill failed"), { code: "ENOENT" }));
|
||||
});
|
||||
|
||||
signalProcessTree(child, "SIGTERM", { platform: "win32", execFile: execFileMock });
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
|
||||
test("does not signal an exited child", () => {
|
||||
const child = createChild({ pid: 1234, exitCode: 0 });
|
||||
const kill = vi.fn(() => true);
|
||||
|
||||
signalProcessTree(child, "SIGTERM", { platform: "darwin", kill });
|
||||
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("terminateProcessTree returns after graceful exit", async () => {
|
||||
const child = createEventedChild({ pid: 1234 });
|
||||
const kill = vi.fn(() => true);
|
||||
|
||||
const resultPromise = terminateProcessTree(child, {
|
||||
platform: "darwin",
|
||||
gracefulTimeoutMs: 100,
|
||||
forceTimeoutMs: 100,
|
||||
kill,
|
||||
});
|
||||
child.emit("exit");
|
||||
|
||||
await expect(resultPromise).resolves.toBe("terminated");
|
||||
expect(kill).toHaveBeenCalledWith(-1234, "SIGTERM");
|
||||
expect(kill).not.toHaveBeenCalledWith(-1234, "SIGKILL");
|
||||
});
|
||||
|
||||
test("terminateProcessTree sends SIGKILL after graceful timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const child = createEventedChild({ pid: 1234 });
|
||||
const kill = vi.fn(() => true);
|
||||
const onForceSignal = vi.fn();
|
||||
|
||||
const resultPromise = terminateProcessTree(child, {
|
||||
platform: "darwin",
|
||||
gracefulTimeoutMs: 100,
|
||||
forceTimeoutMs: 100,
|
||||
kill,
|
||||
onForceSignal,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(onForceSignal).toHaveBeenCalledTimes(1);
|
||||
expect(kill).toHaveBeenNthCalledWith(1, -1234, "SIGTERM");
|
||||
expect(kill).toHaveBeenNthCalledWith(2, -1234, "SIGKILL");
|
||||
|
||||
child.emit("exit");
|
||||
await expect(resultPromise).resolves.toBe("killed");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminateProcessTree reports when forced cleanup does not exit", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const child = createEventedChild({ pid: 1234 });
|
||||
const kill = vi.fn(() => true);
|
||||
|
||||
const resultPromise = terminateProcessTree(child, {
|
||||
platform: "darwin",
|
||||
gracefulTimeoutMs: 100,
|
||||
forceTimeoutMs: 100,
|
||||
kill,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await expect(resultPromise).resolves.toBe("kill-timeout");
|
||||
expect(kill).toHaveBeenNthCalledWith(1, -1234, "SIGTERM");
|
||||
expect(kill).toHaveBeenNthCalledWith(2, -1234, "SIGKILL");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("kills a detached POSIX process group", async () => {
|
||||
const child = spawn("/bin/sh", ["-c", "sleep 60 & wait"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const pid = child.pid;
|
||||
if (typeof pid !== "number") {
|
||||
throw new Error("spawned shell did not expose a pid");
|
||||
}
|
||||
|
||||
let childPids: number[] = [];
|
||||
try {
|
||||
childPids = await waitForChildren(pid);
|
||||
await expect(
|
||||
terminateProcessTree(child, {
|
||||
gracefulTimeoutMs: 1000,
|
||||
forceTimeoutMs: 1000,
|
||||
}),
|
||||
).resolves.toMatch(/^(terminated|killed)$/);
|
||||
await waitForNoLivePids(childPids);
|
||||
} finally {
|
||||
cleanupPidGroup(pid);
|
||||
for (const childPid of childPids) {
|
||||
cleanupPid(childPid);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createChild(options: { pid?: number; exitCode?: number | null } = {}): ProcessTreeTarget {
|
||||
return {
|
||||
pid: options.pid,
|
||||
exitCode: options.exitCode ?? null,
|
||||
signalCode: null,
|
||||
kill: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function createEventedChild(options: { pid?: number }): ProcessTreeTarget & EventEmitter {
|
||||
const child = new EventEmitter() as ProcessTreeTarget & EventEmitter;
|
||||
child.pid = options.pid;
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = vi.fn(() => true);
|
||||
return child;
|
||||
}
|
||||
|
||||
async function waitForChildren(parentPid: number): Promise<number[]> {
|
||||
return waitFor(async () => {
|
||||
const { stdout } = await execFileAsync("pgrep", ["-P", String(parentPid)]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value) && value > 0);
|
||||
return pids.length > 0 ? pids : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForNoLivePids(pids: number[]): Promise<void> {
|
||||
await waitFor(() => (pids.every((pid) => !isPidAlive(pid)) ? true : null));
|
||||
}
|
||||
|
||||
async function waitFor<T>(probe: () => T | null | Promise<T | null>): Promise<T> {
|
||||
const deadline = Date.now() + 3000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const value = await probe();
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("timed out waiting for condition");
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupPidGroup(pid: number): void {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// ignore cleanup races
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupPid(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// ignore cleanup races
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
export interface ProcessTreeTarget {
|
||||
pid?: number;
|
||||
killed?: boolean;
|
||||
exitCode?: number | null;
|
||||
signalCode?: NodeJS.Signals | null;
|
||||
kill(signal?: NodeJS.Signals | number): boolean;
|
||||
once?(event: "exit", listener: () => void): unknown;
|
||||
}
|
||||
|
||||
interface SignalProcessTreeOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
kill?: (pid: number, signal?: NodeJS.Signals | number) => boolean;
|
||||
execFile?: (
|
||||
file: string,
|
||||
args: string[],
|
||||
callback: (error: NodeJS.ErrnoException | null) => void,
|
||||
) => unknown;
|
||||
}
|
||||
|
||||
interface TerminateProcessTreeOptions extends SignalProcessTreeOptions {
|
||||
gracefulSignal?: NodeJS.Signals;
|
||||
forceSignal?: NodeJS.Signals;
|
||||
gracefulTimeoutMs: number;
|
||||
forceTimeoutMs?: number;
|
||||
onForceSignal?: () => void;
|
||||
}
|
||||
|
||||
export type TerminateProcessTreeResult =
|
||||
| "already-exited"
|
||||
| "terminated"
|
||||
| "killed"
|
||||
| "kill-timeout";
|
||||
|
||||
export async function terminateProcessTree(
|
||||
child: ProcessTreeTarget,
|
||||
options: TerminateProcessTreeOptions,
|
||||
): Promise<TerminateProcessTreeResult> {
|
||||
if (isProcessExited(child)) {
|
||||
return "already-exited";
|
||||
}
|
||||
|
||||
const exitPromise = waitForProcessExit(child);
|
||||
signalProcessTree(child, options.gracefulSignal ?? "SIGTERM", options);
|
||||
if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
|
||||
return "terminated";
|
||||
}
|
||||
|
||||
options.onForceSignal?.();
|
||||
signalProcessTree(child, options.forceSignal ?? "SIGKILL", options);
|
||||
if (options.forceTimeoutMs === undefined) {
|
||||
return "killed";
|
||||
}
|
||||
return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
|
||||
? "killed"
|
||||
: "kill-timeout";
|
||||
}
|
||||
|
||||
export function signalProcessTree(
|
||||
child: ProcessTreeTarget,
|
||||
signal: NodeJS.Signals,
|
||||
options: SignalProcessTreeOptions = {},
|
||||
): void {
|
||||
if (isProcessExited(child)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = child.pid;
|
||||
if (typeof pid === "number" && pid > 0) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === "win32") {
|
||||
signalWindowsProcessTree(child, pid, signal, options);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
(options.kill ?? process.kill.bind(process))(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the direct child when no separate process group exists.
|
||||
}
|
||||
}
|
||||
|
||||
signalDirectChild(child, signal);
|
||||
}
|
||||
|
||||
function signalWindowsProcessTree(
|
||||
child: ProcessTreeTarget,
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
options: SignalProcessTreeOptions,
|
||||
): void {
|
||||
const args = ["/pid", String(pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
|
||||
try {
|
||||
(options.execFile ?? execFile)("taskkill", args, (error) => {
|
||||
if (error) {
|
||||
signalDirectChild(child, signal);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
signalDirectChild(child, signal);
|
||||
}
|
||||
}
|
||||
|
||||
function signalDirectChild(child: ProcessTreeTarget, signal: NodeJS.Signals): void {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessExited(child: ProcessTreeTarget): boolean {
|
||||
return (
|
||||
(child.exitCode !== null && child.exitCode !== undefined) ||
|
||||
(child.signalCode !== null && child.signalCode !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function waitForProcessExit(child: ProcessTreeTarget): Promise<void> {
|
||||
if (isProcessExited(child)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!child.once) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
child.once?.("exit", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForExitOrTimeout(
|
||||
exitPromise: Promise<void>,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
exitPromise.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { terminateWithTreeKill } from "./tree-kill.js";
|
||||
|
||||
const pollIntervalMs = 50;
|
||||
|
||||
let tempDir: string | null = null;
|
||||
let ownerProcess: ChildProcess | null = null;
|
||||
let descendantPid: number | null = null;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
async function poll(): Promise<void> {
|
||||
if (await check()) return;
|
||||
if (Date.now() >= deadline) throw new Error(message);
|
||||
await sleep(pollIntervalMs);
|
||||
return poll();
|
||||
}
|
||||
return poll();
|
||||
}
|
||||
|
||||
async function readPidFileNumber(filePath: string): Promise<number | null> {
|
||||
try {
|
||||
const raw = (await readFile(filePath, "utf-8")).trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function killIfRunning(pid: number | null | undefined): void {
|
||||
if (!pid || !isProcessRunning(pid)) return;
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
}
|
||||
|
||||
function spawnOwnerWithDescendant(options: {
|
||||
childPidPath: string;
|
||||
detachedDescendant: boolean;
|
||||
}): ChildProcess {
|
||||
const descendantOptions = options.detachedDescendant
|
||||
? '{ detached: true, stdio: "ignore" }'
|
||||
: '{ stdio: "ignore" }';
|
||||
const childUnref = options.detachedDescendant ? "child.unref();" : "";
|
||||
|
||||
return spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`
|
||||
const { spawn } = require("node:child_process");
|
||||
process.on("SIGTERM", () => {});
|
||||
const child = spawn(process.execPath, [
|
||||
"-e",
|
||||
${JSON.stringify(`
|
||||
const fs = require("node:fs");
|
||||
process.on("SIGTERM", () => {});
|
||||
fs.writeFileSync(${JSON.stringify(options.childPidPath)}, String(process.pid));
|
||||
setInterval(() => {}, 1000);
|
||||
`)}
|
||||
], ${descendantOptions});
|
||||
${childUnref}
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForFixtureReady(childPidPath: string): Promise<void> {
|
||||
await waitFor(
|
||||
async () => {
|
||||
descendantPid = await readPidFileNumber(childPidPath);
|
||||
return (
|
||||
isProcessRunning(ownerProcess?.pid ?? -1) &&
|
||||
descendantPid !== null &&
|
||||
isProcessRunning(descendantPid)
|
||||
);
|
||||
},
|
||||
5000,
|
||||
"owner descendant did not become running in time",
|
||||
);
|
||||
}
|
||||
|
||||
async function expectOwnerAndDescendantStopped(message: string): Promise<void> {
|
||||
await waitFor(
|
||||
() => !isProcessRunning(ownerProcess?.pid ?? -1) && !isProcessRunning(descendantPid ?? -1),
|
||||
5000,
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
killIfRunning(ownerProcess?.pid);
|
||||
killIfRunning(descendantPid);
|
||||
ownerProcess = null;
|
||||
descendantPid = null;
|
||||
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("terminateWithTreeKill", () => {
|
||||
test.runIf(process.platform === "win32")(
|
||||
"kills Windows descendants through taskkill tree cleanup",
|
||||
async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "paseo-server-tree-kill-"));
|
||||
const childPidPath = join(tempDir, "descendant.pid");
|
||||
|
||||
ownerProcess = spawnOwnerWithDescendant({
|
||||
childPidPath,
|
||||
detachedDescendant: false,
|
||||
});
|
||||
expect(ownerProcess.pid).toBeTypeOf("number");
|
||||
await waitForFixtureReady(childPidPath);
|
||||
|
||||
const result = await terminateWithTreeKill(ownerProcess, {
|
||||
gracefulTimeoutMs: 2000,
|
||||
forceTimeoutMs: 2000,
|
||||
});
|
||||
|
||||
// tree-kill uses taskkill /T /F on Windows, so the first signal is already forceful.
|
||||
expect(result).toBe("terminated");
|
||||
await expectOwnerAndDescendantStopped(
|
||||
"owner or Windows descendant survived terminateWithTreeKill",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.runIf(process.platform !== "win32")(
|
||||
"force-kills descendants that started their own process group",
|
||||
async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "paseo-server-tree-kill-"));
|
||||
const childPidPath = join(tempDir, "descendant.pid");
|
||||
|
||||
ownerProcess = spawnOwnerWithDescendant({
|
||||
childPidPath,
|
||||
detachedDescendant: true,
|
||||
});
|
||||
expect(ownerProcess.pid).toBeTypeOf("number");
|
||||
await waitForFixtureReady(childPidPath);
|
||||
|
||||
const result = await terminateWithTreeKill(ownerProcess, {
|
||||
gracefulTimeoutMs: 100,
|
||||
forceTimeoutMs: 2000,
|
||||
});
|
||||
|
||||
expect(result).toBe("killed");
|
||||
await expectOwnerAndDescendantStopped(
|
||||
"owner or separate-process-group descendant survived terminateWithTreeKill",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import treeKill from "tree-kill";
|
||||
|
||||
export interface TreeKillTarget {
|
||||
pid?: number;
|
||||
exitCode?: number | null;
|
||||
signalCode?: NodeJS.Signals | null;
|
||||
kill(signal?: NodeJS.Signals | number): boolean;
|
||||
once?(event: "exit", listener: () => void): unknown;
|
||||
}
|
||||
|
||||
interface TerminateWithTreeKillOptions {
|
||||
gracefulSignal?: NodeJS.Signals;
|
||||
forceSignal?: NodeJS.Signals;
|
||||
gracefulTimeoutMs: number;
|
||||
forceTimeoutMs?: number;
|
||||
onForceSignal?: () => void;
|
||||
}
|
||||
|
||||
export type TerminateWithTreeKillResult =
|
||||
| "already-exited"
|
||||
| "terminated"
|
||||
| "killed"
|
||||
| "kill-timeout";
|
||||
|
||||
export async function terminateWithTreeKill(
|
||||
child: TreeKillTarget,
|
||||
options: TerminateWithTreeKillOptions,
|
||||
): Promise<TerminateWithTreeKillResult> {
|
||||
if (isProcessExited(child)) {
|
||||
return "already-exited";
|
||||
}
|
||||
|
||||
const exitPromise = waitForProcessExit(child);
|
||||
await signalTreeOrChild(child, options.gracefulSignal ?? "SIGTERM");
|
||||
if (await waitForExitOrTimeout(exitPromise, options.gracefulTimeoutMs)) {
|
||||
return "terminated";
|
||||
}
|
||||
|
||||
options.onForceSignal?.();
|
||||
await signalTreeOrChild(child, options.forceSignal ?? "SIGKILL");
|
||||
if (options.forceTimeoutMs === undefined) {
|
||||
return "killed";
|
||||
}
|
||||
return (await waitForExitOrTimeout(exitPromise, options.forceTimeoutMs))
|
||||
? "killed"
|
||||
: "kill-timeout";
|
||||
}
|
||||
|
||||
function signalTreeOrChild(child: TreeKillTarget, signal: NodeJS.Signals): Promise<void> {
|
||||
if (isProcessExited(child)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const pid = child.pid;
|
||||
if (typeof pid !== "number" || pid <= 0) {
|
||||
signalDirectChild(child, signal);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
treeKill(pid, signal, (error) => {
|
||||
if (error) {
|
||||
signalDirectChild(child, signal);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function signalDirectChild(child: TreeKillTarget, signal: NodeJS.Signals): void {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessExited(child: TreeKillTarget): boolean {
|
||||
return (
|
||||
(child.exitCode !== null && child.exitCode !== undefined) ||
|
||||
(child.signalCode !== null && child.signalCode !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function waitForProcessExit(child: TreeKillTarget): Promise<void> {
|
||||
if (isProcessExited(child)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!child.once) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
child.once?.("exit", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForExitOrTimeout(
|
||||
exitPromise: Promise<void>,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
exitPromise.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user