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:
Mohamed Boudra
2026-05-07 13:18:39 +08:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent 7558bd7947
commit 80bdb4d45b
16 changed files with 561 additions and 494 deletions
@@ -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
}
}
-155
View File
@@ -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);
}
}
}
+184
View File
@@ -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",
);
},
);
});
+115
View File
@@ -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);
}
}
}