fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user