/** Shared budget for any renderer await that rides out a primary backend * cold boot (initial getConnection(), the registry restore's descriptor * wait). Matches the main-process spawn budget * (DEFAULT_BACKEND_READY_TIMEOUT_MS in electron/backend-health.ts): a * healthy cold boot publishes well within this; anything longer means the * backend is not coming and the caller should fail instead of hanging. * Reconnect-class awaits against an already-spawned backend use the shorter * RECONNECT_ATTEMPT_TIMEOUT_MS below instead. */ export const BACKEND_BOOT_WAIT_TIMEOUT_MS = 45_000 // desktop.getConnection() / getConnectionFor() / revalidateConnection() / // resolveGatewayWsUrl() are IPC round-trips into the main process with no // timeout of their own (#93454). A wedged main-process round-trip (e.g. a // stuck revalidation after a liveness-probe trip) otherwise hangs an awaiting // caller forever. Every caller of these bounds them with this shared budget. export const RECONNECT_ATTEMPT_TIMEOUT_MS = 20_000 /** Rejection raised by withTimeout. The bounded work is NOT cancelled — the * caller decides what a straggler that settles later means. */ export class TimeoutError extends Error { constructor(message: string) { super(message) this.name = 'TimeoutError' } } export function isTimeoutError(error: unknown): error is TimeoutError { return error instanceof TimeoutError } /** First-run downloads and the user's setup choice are outside the normal * backend cold-start budget. Keep one connection request alive while main * reports that work; a stalled IPC probe and ordinary boots remain bounded. */ export async function withBootstrapAwareTimeout( promise: Promise, ms: number, message: string, bootstrapState?: () => Promise<{ active?: boolean; setupChoice?: unknown } | null>, cancelled: () => boolean = () => false ): Promise { let settled = false let setupObserved = false const request = promise.then( value => { settled = true return value }, error => { settled = true throw error } ) for (;;) { try { return await withTimeout(request, ms, message) } catch (error) { if (settled || cancelled() || !isTimeoutError(error) || !bootstrapState) { throw error } const state = await withTimeout(bootstrapState(), Math.min(ms, 5_000), message) if (state?.active || state?.setupChoice) { setupObserved = true continue } // Setup may have finished just before this probe. The new backend must // still receive one complete cold-start budget after the installer. if (setupObserved) { setupObserved = false continue } throw error } } } /** Settle with `promise`, or reject with a TimeoutError after `ms`. * `onTimeout` runs synchronously before the rejection is published so callers * can revoke ownership of work that would otherwise keep running unowned. If * that callback throws, its error becomes this promise's rejection. */ export function withTimeout( promise: Promise, ms: number, message: string, onTimeout?: (error: TimeoutError) => void ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { const error = new TimeoutError(message) try { onTimeout?.(error) } catch (onTimeoutError) { reject(onTimeoutError) return } reject(error) }, ms) Promise.resolve(promise).then( value => { clearTimeout(timer) resolve(value) }, err => { clearTimeout(timer) reject(err) } ) }) }