Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
217 lines
7.5 KiB
TypeScript
217 lines
7.5 KiB
TypeScript
// 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<number, { label: string; child: ChildProcess }>();
|
|
|
|
/** 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<string, string | undefined> = {}): 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<ProcResult> {
|
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS;
|
|
const label = opts.label ?? command;
|
|
const started = performance.now();
|
|
return new Promise<ProcResult>((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<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
const results = new Array<R>(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<T>(
|
|
what: string,
|
|
probe: () => Promise<T | undefined> | T | undefined,
|
|
{ timeoutMs = 10_000, intervalMs = 25 }: { timeoutMs?: number; intervalMs?: number } = {},
|
|
): Promise<T> {
|
|
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<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|