/** * Cross-platform process tree utilities. * * Windows: uses `taskkill /T /F` for forceful tree termination and * `wmic`/PowerShell for child PID enumeration. * * POSIX: sends signals to the process group (`-pgid`) with a * SIGTERM → SIGKILL escalation (2 s grace). */ import { spawn } from "node:child_process"; import { platform } from "node:os"; // ── Public API ────────────────────────────────────────────────────────────────── /** * Kill a process and its entire child tree. * * On Windows, `taskkill /PID /T` is issued; if `force` is true `/F` is * added. * * On POSIX, the process group `-` is signalled with SIGTERM first. If the * process is still alive after `graceMs` (default 2 000), SIGKILL is sent. */ export async function killProcessTree( pid: number, options?: { force?: boolean; graceMs?: number }, ): Promise { if (!Number.isInteger(pid) || pid <= 0) return; if (platform() === "win32") { await killWindowsTree(pid, options?.force ?? true, options?.graceMs ?? 2_000); return; } await killPosixTree(pid, options?.graceMs ?? 2_000); } /** * Enumerate direct child PIDs for the given parent PID. * Returns an empty array when the platform tooling is unavailable. */ export async function getChildPids(pid: number): Promise { if (!Number.isInteger(pid) || pid <= 0) return []; if (platform() === "win32") return getChildPidsWindows(pid); return getChildPidsPosix(pid); } // ── Windows ───────────────────────────────────────────────────────────────────── async function killWindowsTree( pid: number, force: boolean, graceMs: number, ): Promise { // Ask the full tree to stop first, then escalate if the caller requested a // forceful kill. taskkill returning non-zero usually means it already exited. await run("taskkill", ["/PID", String(pid), "/T"], true); if (!force) return; await sleep(Math.min(Math.max(graceMs, 0), 2_000)); await run("taskkill", ["/PID", String(pid), "/T", "/F"], true); } async function getChildPidsWindows(pid: number): Promise { // Use PowerShell for reliable child enumeration; tasklist parsing is fragile. const script = `Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | ForEach-Object { $_.ProcessId }`; const { stdout } = await run( "pwsh", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], true, ); return parsePidOutput(stdout); } // ── POSIX ─────────────────────────────────────────────────────────────────────── async function killPosixTree(pid: number, graceMs: number): Promise { // Try process group signalling; the process should be a group leader. // All calls are fire-and-forget — ignore ESRCH (process already gone). const signals: NodeJS.Signals[] = ["SIGTERM", "SIGKILL"]; for (const signal of signals) { try { process.kill(-pid, signal); } catch { try { process.kill(pid, signal); } catch { // Already gone. return; } } if (signal === "SIGTERM") await sleep(graceMs); } } async function getChildPidsPosix(pid: number): Promise { // pgrep -P returns direct children. Falls back to ps on older systems. let { stdout } = await run("pgrep", ["-P", String(pid)], true); if (stdout.trim()) return parsePidOutput(stdout); // Fallback: ps -o pid --ppid const result = await run("ps", ["-o", "pid", "--ppid", String(pid)], true); return parsePidOutput(result.stdout); } // ── Helpers ───────────────────────────────────────────────────────────────────── function run( command: string, args: string[], ignoreError = false, ): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString())); child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())); child.on("error", (err) => { if (ignoreError) resolve({ stdout, stderr, code: -1 }); else reject(err); }); child.on("close", (code) => { const exitCode = code ?? -1; if (exitCode !== 0 && !ignoreError) { reject(new Error(`${command} exited with code ${exitCode}: ${stderr}`)); } else { resolve({ stdout, stderr, code: exitCode }); } }); }); } function parsePidOutput(output: string): number[] { return output .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map(Number) .filter((n) => Number.isInteger(n) && n > 0); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }