import type { ChildProcess, SpawnOptions } from "node:child_process"; import { spawn } from "node:child_process"; export type SpawnFallback = { label: string; options: SpawnOptions; }; export type SpawnWithFallbackResult = { child: ChildProcess; usedFallback: boolean; fallbackLabel?: string; }; type SpawnWithFallbackParams = { argv: string[]; options: SpawnOptions; fallbacks?: SpawnFallback[]; spawnImpl?: typeof spawn; retryCodes?: string[]; onFallback?: (err: unknown, fallback: SpawnFallback) => void; }; const DEFAULT_RETRY_CODES = ["EBADF"]; export function resolveCommandStdio(params: { hasInput: boolean; preferInherit: boolean; }): ["pipe" | "inherit" | "ignore", "pipe", "pipe"] { const stdin = params.hasInput ? "pipe" : params.preferInherit ? "inherit" : "pipe"; return [stdin, "pipe", "pipe"]; } export function formatSpawnError(err: unknown): string { if (!(err instanceof Error)) return String(err); const details = err as NodeJS.ErrnoException; const parts: string[] = []; const message = err.message?.trim(); if (message) parts.push(message); if (details.code && !message?.includes(details.code)) parts.push(details.code); if (details.syscall) parts.push(`syscall=${details.syscall}`); if (typeof details.errno === "number") parts.push(`errno=${details.errno}`); return parts.join(" "); } function shouldRetry(err: unknown, codes: string[]): boolean { const code = err && typeof err === "object" && "code" in err ? String((err as { code?: unknown }).code) : ""; return code.length > 0 && codes.includes(code); } async function spawnAndWaitForSpawn( spawnImpl: typeof spawn, argv: string[], options: SpawnOptions, ): Promise { const child = spawnImpl(argv[0], argv.slice(1), options); return await new Promise((resolve, reject) => { let settled = false; const cleanup = () => { child.removeListener("error", onError); child.removeListener("spawn", onSpawn); }; const finishResolve = () => { if (settled) return; settled = true; cleanup(); resolve(child); }; const onError = (err: unknown) => { if (settled) return; settled = true; cleanup(); reject(err); }; const onSpawn = () => { finishResolve(); }; child.once("error", onError); child.once("spawn", onSpawn); // Ensure mocked spawns that never emit "spawn" don't stall. process.nextTick(() => { if (typeof child.pid === "number") { finishResolve(); } }); }); } export async function spawnWithFallback( params: SpawnWithFallbackParams, ): Promise { const spawnImpl = params.spawnImpl ?? spawn; const retryCodes = params.retryCodes ?? DEFAULT_RETRY_CODES; const baseOptions = { ...params.options }; const fallbacks = params.fallbacks ?? []; const attempts: Array<{ label?: string; options: SpawnOptions }> = [ { options: baseOptions }, ...fallbacks.map((fallback) => ({ label: fallback.label, options: { ...baseOptions, ...fallback.options }, })), ]; let lastError: unknown; for (let index = 0; index < attempts.length; index += 1) { const attempt = attempts[index]; try { const child = await spawnAndWaitForSpawn(spawnImpl, params.argv, attempt.options); return { child, usedFallback: index > 0, fallbackLabel: attempt.label, }; } catch (err) { lastError = err; const nextFallback = fallbacks[index]; if (!nextFallback || !shouldRetry(err, retryCodes)) { throw err; } params.onFallback?.(err, nextFallback); } } throw lastError; }