import { constants } from "node:fs"; import { access } from "node:fs/promises"; import { spawn, type ChildProcess } from "node:child_process"; const MAX_TIMEOUT_MS = 2_147_483_647; const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000; const EXIT_STDIO_GRACE_MS = 100; const EXIT_STDIO_MAX_GRACE_MS = 1_000; export interface SpawnOptions { onData: (data: Buffer) => void; signal?: AbortSignal; timeout?: number; env?: NodeJS.ProcessEnv; stdin?: string; } function timeoutMilliseconds(timeout: number | undefined): number | undefined { if (timeout === undefined) return undefined; if (!Number.isFinite(timeout) || timeout <= 0) { throw new Error("Invalid timeout: must be a finite number of seconds greater than zero"); } if (timeout > MAX_TIMEOUT_SECONDS) { throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`); } return timeout * 1000; } /** Kill a Windows process and all descendants without interpolating a command string. */ export function killProcessTree(pid: number | undefined): void { if (!pid || !Number.isInteger(pid) || pid <= 0) return; try { spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, }); } catch { try { process.kill(pid, "SIGTERM"); } catch { // The process may already have exited. } } } /** * Resolve after process exit without waiting forever for stdout/stderr handles * inherited by detached descendants. Active trailing output re-arms the short * post-exit grace period. */ function waitForChildProcess(child: ChildProcess): Promise { return new Promise((resolve, reject) => { let settled = false; let exited = false; let exitCode: number | null = null; let postExitTimer: NodeJS.Timeout | undefined; let postExitDeadline: NodeJS.Timeout | undefined; let stdoutEnded = child.stdout === null; let stderrEnded = child.stderr === null; const cleanup = () => { if (postExitTimer) clearTimeout(postExitTimer); if (postExitDeadline) clearTimeout(postExitDeadline); child.removeListener("error", onError); child.removeListener("exit", onExit); child.removeListener("close", onClose); child.stdout?.removeListener("end", onStdoutEnd); child.stderr?.removeListener("end", onStderrEnd); child.stdout?.removeListener("data", onData); child.stderr?.removeListener("data", onData); }; const finish = (code: number | null) => { if (settled) return; settled = true; cleanup(); child.stdout?.destroy(); child.stderr?.destroy(); resolve(code); }; const maybeFinish = () => { if (exited && stdoutEnded && stderrEnded) finish(exitCode); }; const armGrace = () => { if (postExitTimer) clearTimeout(postExitTimer); postExitTimer = setTimeout(() => finish(exitCode), EXIT_STDIO_GRACE_MS); }; const onData = () => { if (exited && !settled) armGrace(); }; const onStdoutEnd = () => { stdoutEnded = true; maybeFinish(); }; const onStderrEnd = () => { stderrEnded = true; maybeFinish(); }; const onError = (error: Error) => { if (settled) return; settled = true; cleanup(); reject(error); }; const onExit = (code: number | null) => { exited = true; exitCode = code; maybeFinish(); if (!settled) { armGrace(); // A descendant can inherit the pipes and write forever after its // parent exits. Preserve a short active tail, but never let that // keep a completed command open indefinitely. postExitDeadline = setTimeout(() => finish(exitCode), EXIT_STDIO_MAX_GRACE_MS); } }; const onClose = (code: number | null) => finish(code); child.stdout?.once("end", onStdoutEnd); child.stderr?.once("end", onStderrEnd); child.stdout?.on("data", onData); child.stderr?.on("data", onData); child.once("error", onError); child.once("exit", onExit); child.once("close", onClose); }); } export async function spawnAndStream( executable: string, args: string[], cwd: string, options: SpawnOptions, ): Promise<{ exitCode: number | null }> { if (options.signal?.aborted) throw new Error("aborted"); const timeoutMs = timeoutMilliseconds(options.timeout); try { await access(cwd, constants.F_OK); } catch { throw new Error(`Working directory does not exist: ${cwd}`); } const child = spawn(executable, args, { cwd, env: options.env ?? process.env, stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true, }); if (options.stdin !== undefined) { child.stdin?.on("error", () => { // Process startup/termination errors are reported through child events. }); child.stdin?.end(options.stdin, "utf8"); } let timedOut = false; let timeoutHandle: NodeJS.Timeout | undefined; const onAbort = () => killProcessTree(child.pid); const onStdout = (data: Buffer) => options.onData(data); const onStderr = (data: Buffer) => options.onData(data); try { child.stdout?.on("data", onStdout); child.stderr?.on("data", onStderr); if (options.signal) { if (options.signal.aborted) onAbort(); else options.signal.addEventListener("abort", onAbort, { once: true }); } if (timeoutMs !== undefined) { timeoutHandle = setTimeout(() => { timedOut = true; killProcessTree(child.pid); }, timeoutMs); } const exitCode = await waitForChildProcess(child); if (options.signal?.aborted) throw new Error("aborted"); if (timedOut) throw new Error(`timeout:${options.timeout}`); return { exitCode }; } finally { if (timeoutHandle) clearTimeout(timeoutHandle); options.signal?.removeEventListener("abort", onAbort); child.stdout?.removeListener("data", onStdout); child.stderr?.removeListener("data", onStderr); } }