128 lines
3.6 KiB
TypeScript
128 lines
3.6 KiB
TypeScript
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<ChildProcess> {
|
|
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<SpawnWithFallbackResult> {
|
|
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;
|
|
}
|