import { execFile, type ExecFileOptionsWithBufferEncoding, } from "node:child_process"; type ExecFileOutputOptions = Omit< ExecFileOptionsWithBufferEncoding, "encoding" | "maxBuffer" > & { readonly maxBuffer?: number; /** * Signal that stops the process on abort or timeout. The promise then * settles only after that process's stdio pipes close, so a caller can * remove files the command wrote. A descendant that inherited those pipes * keeps them open until it exits; destroying the readers is not * process-family completion and must not unblock this settlement. A process * still running after a grace period is killed. Without this signal, Node's * execFile sends SIGTERM on abort and rejects before the process exits. */ readonly stopSignal?: NodeJS.Signals; }; /** How long a stopped process may take to exit before it is killed. */ const STOP_GRACE_MS = 5_000; /** Cancellation observed by the command owner, with the caller's exact reason. */ export class ExecFileOutputCancelledError extends Error { readonly _tag = "ExecFileOutputCancelledError"; readonly code = "ABORT_ERR"; constructor(reason: unknown) { super("The operation was aborted", { cause: reason }); this.name = "AbortError"; } } /** * A command stopped at its timeout can still exit cleanly, so the timeout is * the failure. Like Node's execFile timeout, it reports `killed: true`. */ const timeoutError = ( command: string, timeout: number, exit: Error | null, ): Error => { const error = new Error( `Command timed out after ${String(timeout)} ms: ${command}`, exit === null ? undefined : { cause: exit }, ); Reflect.set(error, "code", "ETIMEDOUT"); Reflect.set(error, "killed", true); Reflect.set( error, "signal", exit === null ? null : (Reflect.get(exit, "signal") ?? null), ); return error; }; /** * The `timeout` contract for both modes, checked before spawning. A stoppable * command keeps its own deadline, so execFile never validates it. Node 26's * execFile also caps it at Number.MAX_SAFE_INTEGER; Node 22 and 24 accept * larger integers, which their timers then clamp to 1 ms. */ const invalidTimeout = (timeout: number): RangeError | undefined => { if (Number.isSafeInteger(timeout) && timeout >= 0) return undefined; const error = new RangeError( `The value of "timeout" is out of range. It must be an unsigned integer. Received ${String(timeout)}`, ); Reflect.set(error, "code", "ERR_OUT_OF_RANGE"); return error; }; /** Captured subprocess output read from an execFileOutput rejection. */ export interface ExecFileOutputFailure { readonly stdout: string; readonly stderr: string; readonly code: string | number | null; readonly signal: string | null; readonly killed: boolean; readonly outputTruncated: boolean; readonly stdoutBytes?: number; readonly stderrBytes?: number; readonly collectionFailure?: boolean; } /** Read captured output metadata from a failed execFileOutput invocation. */ export const execFileOutputFailure = ( cause: unknown, ): ExecFileOutputFailure | undefined => { if (!(cause instanceof Error)) return undefined; const stdout = Reflect.get(cause, "stdout"); const stderr = Reflect.get(cause, "stderr"); const code = Reflect.get(cause, "code"); const signal = Reflect.get(cause, "signal"); const killed = Reflect.get(cause, "killed"); const outputTruncated = Reflect.get(cause, "outputTruncated"); const stdoutBytes = Reflect.get(cause, "stdoutBytes"); const stderrBytes = Reflect.get(cause, "stderrBytes"); const collectionFailure = Reflect.get(cause, "collectionFailure"); if (typeof stdout !== "string" || typeof stderr !== "string") return undefined; return { stdout, stderr, ...(typeof stdoutBytes === "number" && Number.isSafeInteger(stdoutBytes) && stdoutBytes >= 0 ? { stdoutBytes } : {}), ...(typeof stderrBytes === "number" && Number.isSafeInteger(stderrBytes) && stderrBytes >= 0 ? { stderrBytes } : {}), code: typeof code === "string" || typeof code === "number" ? code : null, signal: typeof signal === "string" ? signal : null, killed: killed === true, outputTruncated: outputTruncated === true || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", ...(collectionFailure === true ? { collectionFailure: true } : {}), }; }; /** Run a shell-free command while capturing its complete output bytes. */ export const execFileBytes = ( command: string, arguments_: readonly string[], options: ExecFileOutputOptions = {}, ): Promise<{ readonly stdout: Buffer; readonly stderr: Buffer }> => new Promise((resolve, reject) => { const { stopSignal, signal, timeout, ...execOptions } = options; const invalid = timeout === undefined ? undefined : invalidTimeout(timeout); if (invalid !== undefined) { reject(invalid); return; } if (stopSignal !== undefined && signal?.aborted === true) { reject(new ExecFileOutputCancelledError(signal.reason)); return; } let stopped: "abort" | "timeout" | undefined; let deadline: NodeJS.Timeout | undefined; let grace: NodeJS.Timeout | undefined; const child = execFile( command, [...arguments_], { ...execOptions, // A stoppable command's abort and timeout are handled below, because // execFile neither waits for the exit on abort nor kills a process // that ignores its timeout signal. ...(stopSignal === undefined ? { ...(signal === undefined ? {} : { signal }), ...(timeout === undefined ? {} : { timeout }), } : { killSignal: stopSignal }), encoding: "buffer", maxBuffer: options.maxBuffer ?? Number.POSITIVE_INFINITY, }, (error, stdout, stderr) => { signal?.removeEventListener("abort", onAbort); clearTimeout(deadline); clearTimeout(grace); const failure = stopped === "abort" && signal !== undefined ? new ExecFileOutputCancelledError(signal.reason) : stopped === "timeout" && timeout !== undefined ? timeoutError(command, timeout, error) : error?.name === "AbortError" && error.code === "ABORT_ERR" ? new ExecFileOutputCancelledError(error.cause) : error; if (failure !== null) { Reflect.set(failure, "stdout", stdout.toString("utf8")); Reflect.set(failure, "stderr", stderr.toString("utf8")); Reflect.set(failure, "stdoutBytes", stdout.byteLength); Reflect.set(failure, "stderrBytes", stderr.byteLength); reject(failure); return; } resolve({ stdout, stderr }); }, ); // The first stop request names the failure; a later one is not resent. const stop = (reason: "abort" | "timeout"): void => { if (stopped !== undefined) return; stopped = reason; child.kill(stopSignal); grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); }; const onAbort = (): void => { stop("abort"); }; if (stopSignal === undefined) return; signal?.addEventListener("abort", onAbort, { once: true }); if (timeout !== undefined && timeout > 0) deadline = setTimeout(() => { stop("timeout"); }, timeout); }); /** Run a shell-free command while capturing its complete UTF-8 output. */ export const execFileOutput = ( command: string, arguments_: readonly string[], options: ExecFileOutputOptions = {}, ): Promise<{ readonly stdout: string; readonly stderr: string; readonly stdoutBytes: number; readonly stderrBytes: number; }> => execFileBytes(command, arguments_, options).then(({ stdout, stderr }) => ({ stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), stdoutBytes: stdout.byteLength, stderrBytes: stderr.byteLength, }));