blob: 454ab2d4bbc67308b3f6c528dbc0bdf065c4abe9 [file]
import { execFile, spawn, type ChildProcess } from 'node:child_process';
export type ProcessTerminationSignal = 'SIGTERM' | 'SIGKILL';
export const DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000;
const WINDOWS_TASKKILL_TIMEOUT_MS = 2000;
const POSIX_PS_PATHS = ['/bin/ps', '/usr/bin/ps'] as const;
interface ProcessTreeTerminationOptions {
pid: number;
signal: ProcessTerminationSignal;
fallback?: () => boolean | void;
hasExited?: () => boolean;
/** Runs after asynchronous topology discovery and before the first OS action. */
beforeSignal?: () => boolean;
}
interface PosixProcess {
pid: number;
ppid: number;
pgid: number;
}
let posixProcessSnapshot: Promise<PosixProcess[]> | undefined;
export function terminateChildProcessTree(
child: ChildProcess,
signal: ProcessTerminationSignal,
): Promise<boolean> {
const pid = child.pid;
if (!pid) return Promise.resolve(false);
return terminateProcessTree({
pid,
signal,
hasExited: () => child.exitCode !== null || child.signalCode !== null,
fallback: () => {
try {
return child.kill(signal);
} catch {
return false;
}
},
});
}
export async function terminateProcessTree(
options: ProcessTreeTerminationOptions,
): Promise<boolean> {
const { pid, signal, fallback, hasExited, beforeSignal } = options;
if (hasExited?.()) return false;
if (process.platform === 'win32') {
if (beforeSignal && !beforeSignal()) return false;
if (await killWindowsTree(pid)) return true;
if (hasExited?.()) return false;
return invokeFallback(fallback);
}
const processes = await readPosixProcesses();
if (hasExited?.()) return false;
if (beforeSignal && !beforeSignal()) return false;
const escapedDescendantSignaled = forceKillEscapedDescendants(pid, processes);
if (hasExited?.()) return escapedDescendantSignaled;
try {
process.kill(-pid, signal);
return true;
} catch (error) {
if (isMissingProcessError(error) || hasExited?.()) return escapedDescendantSignaled;
return invokeFallback(fallback) || escapedDescendantSignaled;
}
}
function forceKillEscapedDescendants(rootPid: number, processes: PosixProcess[]): boolean {
const root = processes.find((entry) => entry.pid === rootPid);
if (!root) return false;
const children = new Map<number, PosixProcess[]>();
for (const processInfo of processes) {
const siblings = children.get(processInfo.ppid) ?? [];
siblings.push(processInfo);
children.set(processInfo.ppid, siblings);
}
const descendants: Array<PosixProcess & { depth: number }> = [];
const seen = new Set([rootPid]);
const pending: Array<{ pid: number; depth: number }> = [{ pid: rootPid, depth: 0 }];
while (pending.length > 0) {
const parent = pending.pop();
if (!parent) break;
for (const child of children.get(parent.pid) ?? []) {
if (seen.has(child.pid)) continue;
seen.add(child.pid);
const depth = parent.depth + 1;
descendants.push({ ...child, depth });
pending.push({ pid: child.pid, depth });
}
}
// Descendants already outside the root group must die before their current
// ancestry disappears. New daemonization after this snapshot is best-effort.
descendants.sort((left, right) => right.depth - left.depth);
let signaled = false;
for (const descendant of descendants) {
if (descendant.pgid === root.pgid) continue;
try {
process.kill(descendant.pid, 'SIGKILL');
signaled = true;
} catch {
// The descendant may have exited between the process snapshot and signal.
}
}
return signaled;
}
function readPosixProcesses(): Promise<PosixProcess[]> {
if (posixProcessSnapshot) return posixProcessSnapshot;
const snapshot = readPosixProcessesUncached();
posixProcessSnapshot = snapshot;
void snapshot.finally(() => {
if (posixProcessSnapshot === snapshot) posixProcessSnapshot = undefined;
});
return snapshot;
}
async function readPosixProcessesUncached(): Promise<PosixProcess[]> {
for (const path of POSIX_PS_PATHS) {
const output = await readPosixProcessTable(path);
if (output === undefined) continue;
try {
const processes = parsePosixProcesses(output);
if (processes.length > 0) return processes;
} catch {
// Try the next fixed system path before degrading to process groups.
}
}
return [];
}
function readPosixProcessTable(path: string): Promise<string | undefined> {
return new Promise((resolve) => {
try {
execFile(
path,
['-axo', 'pid=,ppid=,pgid='],
{
encoding: 'utf8',
maxBuffer: 4 * 1024 * 1024,
timeout: 1_000,
},
(error, output) => {
resolve(error ? undefined : output);
},
);
} catch {
resolve(undefined);
}
});
}
function parsePosixProcesses(output: string): PosixProcess[] {
const processes: PosixProcess[] = [];
for (const line of output.split('\n')) {
const fields = line.trim().split(/\s+/).map(Number);
if (fields.length !== 3 || fields.some((value) => !Number.isSafeInteger(value) || value < 0))
continue;
const [pid, ppid, pgid] = fields;
if (pid === undefined || ppid === undefined || pgid === undefined || pid === 0) continue;
processes.push({ pid, ppid, pgid });
}
return processes;
}
function invokeFallback(fallback: (() => boolean | void) | undefined): boolean {
if (!fallback) return false;
try {
return fallback() !== false;
} catch {
return false;
}
}
function isMissingProcessError(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ESRCH'
);
}
/** Force-kill a Windows process tree and wait for taskkill's exit status. */
export function killWindowsTree(pid: number): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
let timeout: NodeJS.Timeout | undefined;
const finish = (succeeded: boolean) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
resolve(succeeded);
};
try {
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' });
killer.once('error', () => finish(false));
killer.once('close', (code) => finish(code === 0));
timeout = setTimeout(() => {
try {
killer.kill();
} catch {
/* taskkill already exited */
}
finish(false);
}, WINDOWS_TASKKILL_TIMEOUT_MS);
} catch {
finish(false);
}
});
}