541 lines
21 KiB
TypeScript
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 };
|
|
}
|