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; 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; readonly stdout: Promise; readonly stderr: Promise; /** 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; } 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; finish(): void } { let text = ""; let bytes = 0; let truncated = false; let settled = false; let resolveOutput!: (value: OwnedFixtureProcessOutput) => void; const output = new Promise((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; readonly stdout: Promise; readonly stderr: Promise; 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(); private rootExited = false; private stopPromise: Promise | 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((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 { if (milliseconds <= 0) return; if (this.adapter.sleep) await this.adapter.sleep(milliseconds); else await new Promise((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 { 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 { 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 { 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 { const timeoutMs = boundedOption(options.timeoutMs, DEFAULT_HOOK_TIMEOUT_MS, MAX_TIMEOUT_MS, "hook timeout"); let timer: ReturnType | 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 }; }