import { spawn, type ChildProcess } from "node:child_process" import type { EnsureTiming } from "./service-timing.js" export type ServiceContender = { readonly child: ChildProcess readonly error: () => Error | undefined readonly closed: () => boolean readonly stderr: () => string readonly release: () => void } const stderrLimit = 8 * 1024 export function spawnServiceContender( command: string, args: ReadonlyArray, env?: Readonly>, ): ServiceContender { const child = spawn(command, args, { detached: true, windowsHide: true, stdio: ["ignore", "ignore", "pipe"], env: { ...process.env, ...env }, }) let error: Error | undefined let closed = false let stderr = Buffer.alloc(0) const onStderr = (chunk: Buffer) => { const tail = chunk.subarray(-stderrLimit) stderr = tail.length === stderrLimit ? Buffer.from(tail) : Buffer.concat([stderr.subarray(-(stderrLimit - tail.length)), tail]) } child.stderr?.on("data", onStderr) if (child.stderr !== null && "unref" in child.stderr && typeof child.stderr.unref === "function") child.stderr.unref() child.once("error", (cause) => { error = new Error("Failed to start server", { cause }) }) child.once("close", () => { closed = true }) child.unref() return { child, error: () => error, closed: () => closed, stderr: () => stderr.toString("utf8").trim(), release: () => { child.stderr?.off("data", onStderr) child.stderr?.resume() stderr = Buffer.alloc(0) }, } } /** * The startup attempts of one `ensure()` call. It keeps at most two attempts alive, remembers * the first startup failure, and backs off when attempts exit cleanly because another one won. */ export function contenderPool(timing: EnsureTiming) { const contenders = new Set() let failure: Error | undefined let spawnDelay = timing.spawnDelay let lastSpawn = 0 return { /** The first startup failure seen since the last eviction. */ failure: () => failure, /** A registered service answered, so the next attempt waits the base delay again. */ serviceAnswered() { spawnDelay = timing.spawnDelay }, /** * The owner `pid` was replaced. Drop its attempt and any finished ones, forget their * failure, and restart the spawn clock. */ evict(pid: number) { for (const item of contenders) { if (item.child.pid === pid || contenderFinished(item)) { item.release() contenders.delete(item) } } failure = undefined lastSpawn = 0 }, /** Let the next attempt start without waiting a spawn delay. */ recruitNow() { lastSpawn = Date.now() - spawnDelay }, /** Collect finished attempts. Returns the startup failure once no attempt is left alive. */ reap() { const finished = [...contenders].filter(contenderFinished) failure ??= finished.map(contenderFailure).find((error) => error !== undefined) if (finished.some((item) => item.child.exitCode === 0)) spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay) finished.forEach((item) => contenders.delete(item)) return contenders.size === 0 ? failure : undefined }, /** * Whether to start another attempt now. A registration that has not answered yet gets one * spawn delay before an attempt competes with it. */ shouldRecruit(registered: boolean) { if (lastSpawn === 0 && registered) lastSpawn = Date.now() // Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the // survivors finish without recruiting replacements that could hide the error indefinitely. return failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay }, add(contender: ServiceContender) { contenders.add(contender) lastSpawn = Date.now() }, releaseAll() { contenders.forEach((contender) => contender.release()) }, } } function contenderFailure(contender: ServiceContender) { const error = contender.error() if (error !== undefined) return error if (contender.child.exitCode !== null && contender.child.exitCode !== 0) return startupError(`Server process exited with code ${contender.child.exitCode}`, contender.stderr()) if (contender.child.signalCode !== null) return startupError(`Server process terminated by ${contender.child.signalCode}`, contender.stderr()) return undefined } export function contenderFinished(contender: ServiceContender) { return contender.error() !== undefined || contender.closed() } function startupError(message: string, stderr: string) { return new Error(stderr ? `${message}\n${stderr}` : message) }