Files
harness-tools/extensions/fast-browser/fixture-process.ts
T
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

541 lines
21 KiB
TypeScript

import { spawn as nodeSpawn } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
/**
* Fixture processes are intentionally a small, Linux-only foreground-process
* primitive. A command that daemonizes, calls setsid, or otherwise leaves its
* process group is outside this guarantee; this module is not a sandbox.
*/
const DEFAULT_TERM_TIMEOUT_MS = 250;
const DEFAULT_KILL_TIMEOUT_MS = 250;
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
const DEFAULT_OUTPUT_BYTES = 64 * 1024;
const MAX_TIMEOUT_MS = 120_000;
const MAX_OUTPUT_BYTES = 1024 * 1024;
export type FixtureProcessSignal = "SIGTERM" | "SIGKILL";
export interface FixtureProcessIdentity {
pid: number;
pgrp: number;
session: number;
startTime: string;
state?: string;
}
interface FixtureReadable {
on(event: "data" | "error", listener: (...args: unknown[]) => void): unknown;
}
/** The complete child seam used by tests; it never needs a real PID. */
export interface FixtureChildProcess {
readonly pid?: number;
once(event: "spawn" | "exit" | "close" | "error", listener: (...args: any[]) => void): unknown;
readonly stdout?: FixtureReadable | null;
readonly stderr?: FixtureReadable | null;
}
export interface FixtureSpawnOptions {
cwd: string;
env: NodeJS.ProcessEnv;
shell: false;
detached: true;
stdio: ["ignore", "pipe", "pipe"];
}
/**
* Every operation that could observe or signal a process is injectable. This
* keeps fake PIDs in tests away from the host OS and makes ownership decisions
* explicit rather than hiding them behind process.kill.
*/
export interface FixtureProcessAdapter {
readonly platform?: NodeJS.Platform;
spawn(command: string, args: readonly string[], options: FixtureSpawnOptions): FixtureChildProcess;
identity(pid: number): FixtureProcessIdentity | undefined;
groupMembers(groupId: number): readonly FixtureProcessIdentity[] | undefined;
signalGroup(groupId: number, signal: FixtureProcessSignal): void;
/** Optional non-signaling existence probe; true includes EPERM. */
groupAlive?(groupId: number): boolean | undefined;
sleep?(milliseconds: number): Promise<void>;
now?(): number;
}
export interface StartOwnedFixtureProcessOptions {
/** Explicit executable and arguments. Shell parsing is never performed. */
argv: readonly string[];
cwd: string;
/** Environment has already been resolved by the caller. */
env: NodeJS.ProcessEnv;
adapter?: FixtureProcessAdapter;
termTimeoutMs?: number;
killTimeoutMs?: number;
maxStdoutBytes?: number;
maxStderrBytes?: number;
}
export interface OwnedFixtureProcessExit {
code: number | null;
signal: string | null;
/** Set only when the executable could not be started. */
startFailed?: boolean;
}
export interface OwnedFixtureProcessOutput {
text: string;
truncated: boolean;
}
export interface OwnedFixtureProcessHandle {
readonly pid: number | undefined;
readonly exit: Promise<OwnedFixtureProcessExit>;
readonly stdout: Promise<OwnedFixtureProcessOutput>;
readonly stderr: Promise<OwnedFixtureProcessOutput>;
/** False means dead, and may also mean that liveness could not be proven. */
isAlive(): boolean;
/** Stops only a verified owned group. It never falls back to killing a PID. */
stop(): Promise<void>;
}
export interface RunOwnedFixtureHookOptions extends StartOwnedFixtureProcessOptions {
timeoutMs?: number;
}
export interface OwnedFixtureHookResult {
exit: OwnedFixtureProcessExit;
stdout: OwnedFixtureProcessOutput;
stderr: OwnedFixtureProcessOutput;
}
export class FixtureProcessError extends Error {
readonly kind: "unsupported_platform" | "invalid_configuration" | "start" | "ownership" | "cleanup" | "cancelled" | "timeout" | "failed";
override name = "FixtureProcessError";
constructor(kind: FixtureProcessError["kind"], message: string) {
super(message);
this.kind = kind;
}
}
function validPid(pid: unknown): pid is number {
return Number.isSafeInteger(pid) && (pid as number) > 1 && (pid as number) !== process.pid;
}
function nativeIdentity(pid: number): FixtureProcessIdentity | undefined {
if (!validPid(pid) || process.platform !== "linux") return undefined;
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const end = stat.lastIndexOf(") ");
if (end < 0) return undefined;
const fields = stat.slice(end + 2).trim().split(/\s+/);
const state = fields[0];
const pgrp = Number(fields[2]);
const session = Number(fields[3]);
const startTime = fields[19];
if (!state || !Number.isSafeInteger(pgrp) || pgrp < 2 || !Number.isSafeInteger(session) || session < 2 || !startTime) return undefined;
return { pid, pgrp, session, startTime, state };
} catch {
return undefined;
}
}
function nativeGroupMembers(groupId: number): readonly FixtureProcessIdentity[] | undefined {
if (process.platform !== "linux" || !validPid(groupId)) return undefined;
const members: FixtureProcessIdentity[] = [];
try {
for (const entry of readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) continue;
const identity = nativeIdentity(Number(entry));
if (identity?.pgrp === groupId) members.push(identity);
}
} catch {
return undefined;
}
return members;
}
const nativeAdapter: FixtureProcessAdapter = {
platform: process.platform,
spawn(command, args, options) {
return nodeSpawn(command, [...args], options) as unknown as FixtureChildProcess;
},
identity: nativeIdentity,
groupMembers: nativeGroupMembers,
groupAlive(groupId) {
if (!validPid(groupId)) return undefined;
try { process.kill(-groupId, 0); return true; }
catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ESRCH") return false;
if ((error as NodeJS.ErrnoException)?.code === "EPERM") return true;
return undefined;
}
},
signalGroup(groupId, signal) {
// The caller verifies this again; retaining the guard here prevents an
// accidental future caller from ever turning this into kill(0/-1).
if (!validPid(groupId)) {
throw new FixtureProcessError("ownership", "fixture process-group identity could not be confirmed");
}
process.kill(-groupId, signal);
},
};
function adapterOrNative(adapter: FixtureProcessAdapter | undefined): FixtureProcessAdapter {
return adapter ?? nativeAdapter;
}
function boundedOption(value: number | undefined, fallback: number, maximum: number, label: string): number {
const result = value ?? fallback;
if (!Number.isSafeInteger(result) || result < 1 || result > maximum) {
throw new FixtureProcessError("invalid_configuration", `fixture ${label} is out of bounds`);
}
return result;
}
function validateOptions(options: StartOwnedFixtureProcessOptions): void {
if (!Array.isArray(options.argv) || options.argv.length < 1 || options.argv.length > 128 || options.argv.some((value) => typeof value !== "string" || value.length === 0 || value.length > 4_000 || value.includes("\0"))) {
throw new FixtureProcessError("invalid_configuration", "fixture process argv is invalid");
}
if (typeof options.cwd !== "string" || options.cwd.length === 0 || options.cwd.length > 4_000 || options.cwd.includes("\0")) {
throw new FixtureProcessError("invalid_configuration", "fixture process cwd is invalid");
}
if (options.env === null || typeof options.env !== "object" || Array.isArray(options.env)) {
throw new FixtureProcessError("invalid_configuration", "fixture process environment is invalid");
}
for (const [name, value] of Object.entries(options.env)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof value !== "string" || value.includes("\0")) {
throw new FixtureProcessError("invalid_configuration", "fixture process environment is invalid");
}
}
}
function safeError(kind: FixtureProcessError["kind"], message: string): FixtureProcessError {
return new FixtureProcessError(kind, message);
}
function outputCapture(stream: FixtureReadable | null | undefined, limit: number): { output: Promise<OwnedFixtureProcessOutput>; finish(): void } {
let text = "";
let bytes = 0;
let truncated = false;
let settled = false;
let resolveOutput!: (value: OwnedFixtureProcessOutput) => void;
const output = new Promise<OwnedFixtureProcessOutput>((resolve) => { resolveOutput = resolve; });
const append = (chunk: unknown): void => {
if (settled || truncated) return;
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(typeof chunk === "string" ? chunk : String(chunk));
if (bytes + value.byteLength <= limit) {
text += value.toString();
bytes += value.byteLength;
return;
}
const remaining = Math.max(0, limit - bytes);
if (remaining > 0) text += value.subarray(0, remaining).toString();
bytes = limit;
truncated = true;
};
stream?.on("data", append);
stream?.on("error", () => undefined);
return {
output,
finish() {
if (settled) return;
settled = true;
resolveOutput({ text, truncated });
},
};
}
function isSignal(value: unknown): value is string {
return typeof value === "string" && /^SIG[A-Z0-9]+$/.test(value);
}
class OwnedFixtureProcess implements OwnedFixtureProcessHandle {
readonly pid: number | undefined;
readonly exit: Promise<OwnedFixtureProcessExit>;
readonly stdout: Promise<OwnedFixtureProcessOutput>;
readonly stderr: Promise<OwnedFixtureProcessOutput>;
private readonly adapter: FixtureProcessAdapter;
private readonly termTimeoutMs: number;
private readonly killTimeoutMs: number;
private readonly child: FixtureChildProcess;
private rootIdentity: FixtureProcessIdentity | undefined;
private groupId: number | undefined;
private readonly parentSignal: AbortSignal;
private readonly removeParentAbort: () => void;
private readonly finishStdout: () => void;
private readonly finishStderr: () => void;
private readonly knownStarts = new Map<number, string>();
private rootExited = false;
private stopPromise: Promise<void> | undefined;
private exitResolve!: (value: OwnedFixtureProcessExit) => void;
private exitValue: OwnedFixtureProcessExit | undefined;
constructor(
child: FixtureChildProcess,
adapter: FixtureProcessAdapter,
options: StartOwnedFixtureProcessOptions,
parentSignal: AbortSignal,
) {
this.child = child;
this.adapter = adapter;
this.pid = child.pid;
this.parentSignal = parentSignal;
this.termTimeoutMs = boundedOption(options.termTimeoutMs, DEFAULT_TERM_TIMEOUT_MS, MAX_TIMEOUT_MS, "SIGTERM timeout");
this.killTimeoutMs = boundedOption(options.killTimeoutMs, DEFAULT_KILL_TIMEOUT_MS, MAX_TIMEOUT_MS, "SIGKILL timeout");
const stdout = outputCapture(child.stdout, boundedOption(options.maxStdoutBytes, DEFAULT_OUTPUT_BYTES, MAX_OUTPUT_BYTES, "stdout limit"));
const stderr = outputCapture(child.stderr, boundedOption(options.maxStderrBytes, DEFAULT_OUTPUT_BYTES, MAX_OUTPUT_BYTES, "stderr limit"));
this.stdout = stdout.output;
this.stderr = stderr.output;
this.finishStdout = stdout.finish;
this.finishStderr = stderr.finish;
this.exit = new Promise<OwnedFixtureProcessExit>((resolve) => { this.exitResolve = resolve; });
const identity = this.pid !== undefined && validPid(this.pid) ? adapter.identity(this.pid) : undefined;
if (identity && this.pid !== undefined && identity.pid === this.pid && identity.pgrp === this.pid && identity.session === this.pid && this.validGroupId(identity.pgrp)) {
this.rootIdentity = identity;
this.groupId = identity.pgrp;
this.knownStarts.set(identity.pid, identity.startTime);
}
const onSpawn = (): void => { this.refreshIdentity(); };
const onExit = (code: number | null, signal: string | null): void => {
this.rootExited = true;
this.finishExit({ code: typeof code === "number" ? code : null, signal: isSignal(signal) ? signal : (signal ?? null) });
this.finishStdout();
this.finishStderr();
};
const onError = (): void => {
this.rootExited = true;
this.finishExit({ code: null, signal: null, startFailed: true });
this.finishStdout();
this.finishStderr();
};
child.once("spawn", onSpawn);
child.once("exit", onExit);
child.once("close", onExit);
child.once("error", onError);
const onParentAbort = (): void => { void this.stop().catch(() => undefined); };
this.removeParentAbort = () => parentSignal.removeEventListener("abort", onParentAbort);
if (parentSignal.aborted) {
onParentAbort();
} else {
parentSignal.addEventListener("abort", onParentAbort, { once: true });
}
}
private validGroupId(groupId: number): boolean {
return validPid(groupId);
}
private refreshIdentity(): void {
if (this.rootIdentity || this.pid === undefined || !validPid(this.pid)) return;
const identity = this.adapter.identity(this.pid);
if (!identity || identity.pid !== this.pid || identity.pgrp !== this.pid || identity.session !== this.pid || !this.validGroupId(identity.pgrp)) return;
this.rootIdentity = identity;
this.groupId = identity.pgrp;
this.knownStarts.set(identity.pid, identity.startTime);
}
private finishExit(value: OwnedFixtureProcessExit): void {
if (this.exitValue) return;
this.exitValue = value;
this.exitResolve(value);
}
private groupState(): "alive" | "dead" | "uncertain" {
const groupId = this.groupId ?? this.pid;
if (!groupId || !this.validGroupId(groupId)) return this.rootExited ? "dead" : "uncertain";
const members = this.adapter.groupMembers(groupId);
if (members === undefined) return "uncertain";
if (members.length === 0) {
// An empty /proc scan while the group is still observable is a race or
// inspection failure, not proof that the group is gone.
if (this.adapter.groupAlive?.(groupId) === true) return "uncertain";
const currentRoot = this.pid === undefined ? undefined : this.adapter.identity(this.pid);
if (currentRoot || !this.rootExited) return "uncertain";
return "dead";
}
const liveMembers = members.filter((member) => member.state !== "Z");
if (liveMembers.length === 0) return "dead";
return "alive";
}
private ownsGroup(): boolean {
const pid = this.pid;
const groupId = this.groupId;
const root = this.rootIdentity;
if (!pid || !groupId || !root || !this.validGroupId(groupId) || root.pid !== pid || root.pgrp !== groupId || root.session !== groupId) return false;
const currentRoot = this.adapter.identity(pid);
if (!currentRoot && !this.rootExited) return false;
if (currentRoot && (currentRoot.startTime !== root.startTime || currentRoot.pgrp !== groupId || currentRoot.session !== groupId)) return false;
const members = this.adapter.groupMembers(groupId);
if (!members || members.length === 0) return false;
for (const member of members) {
if (!validPid(member.pid) || member.pgrp !== groupId || member.session !== groupId) return false;
const previous = this.knownStarts.get(member.pid);
if (previous !== undefined && previous !== member.startTime) return false;
this.knownStarts.set(member.pid, member.startTime);
if (member.pid === pid && member.startTime !== root.startTime) return false;
}
return true;
}
private now(): number { return this.adapter.now?.() ?? Date.now(); }
private async sleep(milliseconds: number): Promise<void> {
if (milliseconds <= 0) return;
if (this.adapter.sleep) await this.adapter.sleep(milliseconds);
else await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
private async waitDead(timeoutMs: number): Promise<"dead" | "alive" | "uncertain"> {
const deadline = this.now() + timeoutMs;
while (true) {
const state = this.groupState();
if (state !== "alive") return state;
if (this.now() >= deadline) return "alive";
await this.sleep(Math.min(20, Math.max(1, deadline - this.now())));
}
}
private async signalGroup(signal: FixtureProcessSignal): Promise<void> {
const groupId = this.groupId;
if (!groupId || !this.validGroupId(groupId) || !this.ownsGroup()) {
throw safeError("ownership", "fixture process-group ownership could not be confirmed");
}
try {
this.adapter.signalGroup(groupId, signal);
} catch {
// ESRCH is represented by the next liveness check. Never expose the
// adapter's details because they may contain paths or command strings.
}
}
private finalizeStopped(signal: FixtureProcessSignal = "SIGTERM"): void {
if (!this.exitValue) {
this.rootExited = true;
this.finishExit({ code: null, signal });
this.finishStdout();
this.finishStderr();
}
}
private async stopInternal(): Promise<void> {
let state = this.groupState();
if (state === "dead") {
this.finalizeStopped();
this.removeParentAbort();
return;
}
if (state === "uncertain") throw safeError("ownership", "fixture process liveness could not be confirmed");
await this.signalGroup("SIGTERM");
state = await this.waitDead(this.termTimeoutMs);
if (state === "dead") {
this.finalizeStopped("SIGTERM");
this.removeParentAbort();
return;
}
if (state === "uncertain") throw safeError("ownership", "fixture process ownership could not be confirmed before escalation");
await this.signalGroup("SIGKILL");
state = await this.waitDead(this.killTimeoutMs);
if (state !== "dead") throw safeError("cleanup", "fixture process-group termination could not be confirmed");
this.finalizeStopped("SIGKILL");
this.removeParentAbort();
}
isAlive(): boolean { return this.groupState() === "alive"; }
stop(): Promise<void> {
if (!this.stopPromise) {
const attempt = this.stopInternal();
this.stopPromise = attempt.catch((error) => {
this.stopPromise = undefined;
throw error;
});
}
return this.stopPromise;
}
}
/** Start one detached, dedicated, owned foreground process. */
export function startOwnedFixtureProcess(
options: StartOwnedFixtureProcessOptions,
signal: AbortSignal = new AbortController().signal,
): OwnedFixtureProcessHandle {
const adapter = adapterOrNative(options.adapter);
validateOptions(options);
const platform = adapter.platform ?? process.platform;
if (platform !== "linux") {
throw safeError("unsupported_platform", "owned fixture process supervision is unavailable on this platform");
}
if (signal.aborted) throw safeError("cancelled", "fixture process cancelled");
let child: FixtureChildProcess;
try {
child = adapter.spawn(options.argv[0]!, options.argv.slice(1), {
cwd: options.cwd,
env: { ...options.env },
shell: false,
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
throw safeError("start", "fixture process failed to start");
}
return new OwnedFixtureProcess(child, adapter, options, signal);
}
/**
* Run a finite hook and always clean up its whole owned group, including when
* the root exits successfully while a foreground descendant remains alive.
*/
export async function runOwnedFixtureHook(
options: RunOwnedFixtureHookOptions,
signal: AbortSignal = new AbortController().signal,
): Promise<OwnedFixtureHookResult> {
const timeoutMs = boundedOption(options.timeoutMs, DEFAULT_HOOK_TIMEOUT_MS, MAX_TIMEOUT_MS, "hook timeout");
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
let cancelled = signal.aborted;
let removeAbortListener = (): void => undefined;
const abortPromise = new Promise<"abort">((resolve) => {
if (signal.aborted) resolve("abort");
else {
const onAbort = (): void => { cancelled = true; resolve("abort"); };
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
}
});
let handle: OwnedFixtureProcessHandle;
try {
handle = startOwnedFixtureProcess(options, signal);
} catch (error) {
removeAbortListener();
throw error;
}
const timeoutPromise = new Promise<"timeout">((resolve) => {
timer = setTimeout(() => { timedOut = true; resolve("timeout"); }, timeoutMs);
});
const exitPromise = handle.exit.then((exit) => ({ kind: "exit" as const, exit }));
const outcome = await Promise.race([
exitPromise,
abortPromise.then((kind) => ({ kind, exit: undefined })),
timeoutPromise.then((kind) => ({ kind, exit: undefined })),
]);
if (outcome.kind === "abort") cancelled = true;
if (timer) clearTimeout(timer);
removeAbortListener();
try {
await handle.stop();
} catch {
throw safeError("cleanup", "fixture hook process-group cleanup failed");
}
const exit = outcome.exit ?? await handle.exit;
if (cancelled) throw safeError("cancelled", "fixture hook cancelled");
if (timedOut || outcome.kind === "timeout") throw safeError("timeout", "fixture hook exceeded its deadline");
if (exit.startFailed) throw safeError("start", "fixture hook failed to start");
if (exit.code !== 0) throw safeError("failed", "fixture hook failed");
return { exit, stdout: await handle.stdout, stderr: await handle.stderr };
}