114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
/** 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<T>(
|
|
promise: Promise<T>,
|
|
ms: number,
|
|
message: string,
|
|
bootstrapState?: () => Promise<{ active?: boolean; setupChoice?: unknown } | null>,
|
|
cancelled: () => boolean = () => false
|
|
): Promise<T> {
|
|
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<T>(
|
|
promise: Promise<T>,
|
|
ms: number,
|
|
message: string,
|
|
onTimeout?: (error: TimeoutError) => void
|
|
): Promise<T> {
|
|
return new Promise<T>((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)
|
|
}
|
|
)
|
|
})
|
|
}
|