// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * Process spawning with mandatory timeouts, exact argv (never a shell unless asked), * and leak tracking. */ import { spawn, type ChildProcess } from 'node:child_process'; import { HarnessError } from './errors'; import { PROC_MARKER_ENV, runId } from './env'; export interface ProcResult { /** Executable that was run. */ command: string; /** Arguments exactly as passed (not including the command). */ argv: string[]; stdout: string; stderr: string; stdoutBuffer: Buffer; stderrBuffer: Buffer; /** Exit status, or null when terminated by a signal. */ exitCode: number | null; /** Terminating signal, or null on a normal exit. */ signal: NodeJS.Signals | null; /** True when the harness killed the process because its deadline passed. */ timedOut: boolean; durationMs: number; } export interface RunProcessOptions { /** Complete environment of the child (nothing is inherited implicitly). */ env: NodeJS.ProcessEnv; cwd?: string; /** Hard deadline; the whole process group is SIGKILLed when it passes. Default 30 s. */ timeoutMs?: number; /** Data written to stdin (stdin is /dev/null otherwise). */ input?: string | Buffer; /** Return (instead of throwing HarnessError) when the deadline passes. */ allowTimeout?: boolean; /** Return (instead of throwing HarnessError) when the process dies from a signal. */ allowSignal?: boolean; /** Human label used in error messages. */ label?: string; } export const DEFAULT_PROCESS_TIMEOUT_MS = 30_000; /** Processes spawned by this worker that have not been observed to exit yet. */ const live = new Map(); /** Snapshot of processes spawned by this worker that are still running. */ export function liveProcesses(): Array<{ pid: number; label: string }> { return [...live.entries()].map(([pid, v]) => ({ pid, label: v.label })); } /** Registers a long-running child (e.g. a server) for leak tracking; returns an unregister fn. */ export function trackProcess(child: ChildProcess, label: string): void { if (child.pid === undefined) return; const pid = child.pid; live.set(pid, { label, child }); child.once('exit', () => live.delete(pid)); child.once('error', () => live.delete(pid)); } /** Kills the whole process group of a detached child (best effort). */ export function killGroup(pid: number, signal: NodeJS.Signals = 'SIGKILL'): void { try { process.kill(-pid, signal); } catch { try { process.kill(pid, signal); } catch { /* already gone */ } } } /** Environment for harness-spawned helper tools (docker, curl, sh): inherits the worker env plus the leak marker. */ export function toolEnv(extra: Record = {}): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, [PROC_MARKER_ENV]: runId() }; for (const [k, v] of Object.entries(extra)) { if (v === undefined) delete env[k]; else env[k] = v; } return env; } /** * Runs a command to completion. argv is passed verbatim (no shell). * Throws HarnessError('spawn') if it cannot start, ('timeout') on deadline, * ('signal') on signal termination, unless explicitly allowed. */ export function runProcess(command: string, argv: string[], opts: RunProcessOptions): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS; const label = opts.label ?? command; const started = performance.now(); return new Promise((resolve, reject) => { let child: ChildProcess; try { child = spawn(command, argv, { env: opts.env, cwd: opts.cwd, stdio: [opts.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], detached: true, // own process group: the deadline kills descendants too shell: false, }); } catch (e) { reject(new HarnessError('spawn', `cannot spawn ${label}: ${(e as Error).message}`)); return; } trackProcess(child, label); const out: Buffer[] = []; const err: Buffer[] = []; let timedOut = false; let spawnError: Error | undefined; child.stdout!.on('data', (d: Buffer) => out.push(d)); child.stderr!.on('data', (d: Buffer) => err.push(d)); if (opts.input !== undefined) { child.stdin!.on('error', () => { /* EPIPE when the child does not read stdin: irrelevant */ }); child.stdin!.end(opts.input); } const timer = setTimeout(() => { timedOut = true; if (child.pid !== undefined) killGroup(child.pid, 'SIGKILL'); }, timeoutMs); child.on('error', (e) => { spawnError = e; }); child.on('close', (code, signal) => { clearTimeout(timer); // Also reap any descendants that kept running after the main process exited if (child.pid !== undefined) killGroup(child.pid, 'SIGKILL'); const stdoutBuffer = Buffer.concat(out); const stderrBuffer = Buffer.concat(err); const result: ProcResult = { command, argv: [...argv], stdout: stdoutBuffer.toString('utf8'), stderr: stderrBuffer.toString('utf8'), stdoutBuffer, stderrBuffer, exitCode: code, signal, timedOut, durationMs: performance.now() - started, }; if (spawnError) { // Node reports spawn failures (ENOENT, E2BIG, EACCES) as a negative errno exit code reject(new HarnessError('spawn', `cannot spawn ${label}: ${spawnError.message}`, result)); return; } if (timedOut && !opts.allowTimeout) { reject( new HarnessError( 'timeout', `${label} did not finish within ${timeoutMs} ms and was killed\n--- stdout (tail)\n${tail(result.stdout)}\n--- stderr (tail)\n${tail(result.stderr)}`, result, ), ); return; } if (!timedOut && signal !== null && !opts.allowSignal) { reject( new HarnessError( 'signal', `${label} was terminated by unexpected signal ${signal}\n--- stderr (tail)\n${tail(result.stderr)}`, result, ), ); return; } resolve(result); }); }); } /** Last n characters of a string, for error messages. */ export function tail(s: string, n = 2000): string { return s.length <= n ? s : '…' + s.slice(-n); } /** Runs async tasks with bounded concurrency, preserving result order. */ export async function mapLimit(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise): Promise { const results = new Array(items.length); let next = 0; const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { while (true) { const i = next++; if (i >= items.length) return; results[i] = await fn(items[i], i); } }); await Promise.all(workers); return results; } /** Polls `probe` until it returns a non-undefined value or the deadline passes (HarnessError 'timeout'). */ export async function pollUntil( what: string, probe: () => Promise | T | undefined, { timeoutMs = 10_000, intervalMs = 25 }: { timeoutMs?: number; intervalMs?: number } = {}, ): Promise { const deadline = performance.now() + timeoutMs; while (true) { const v = await probe(); if (v !== undefined) return v; if (performance.now() > deadline) throw new HarnessError('timeout', `timed out after ${timeoutMs} ms waiting for ${what}`); await sleep(intervalMs); } } export function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }