Files
aiturk-hermes-ide/apps/desktop/src/lib/with-timeout.ts
T

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)
}
)
})
}