Files
hermes-agent/apps/desktop/src/lib/with-timeout.ts
T
nftpoetrist d81b801fb4 fix(desktop): bound getConnection()/resolveGatewayWsUrl() on every remaining route
20s withTimeout() on the boot() and soft-switch paths (use-gateway-boot.ts),
since these are IPC round-trips into the main process with no timeout of
their own — a wedged main-process round-trip hangs the awaiting caller
forever instead of surfacing a failure.

Every other production call site of the same IPC pair was still unbounded:

- store/gateway.ts's openSecondary() and sharedPrimaryRoute() — the actual
  connection-establishment underneath requestGatewayForProfile/Agent,
  ensureGatewayForProfile/Agent, and every other exported routing entry
  point that opens a non-primary profile's socket.
- use-gateway-request.ts's on-demand reconnect (the primary gateway's
  "not connected" retry path hit by every RPC).
- voice-playback.ts's resolveSpeakStreamUrl().
- api/plugins.ts's activeConnection() (pluginSocket's connect()).

Extracted RECONNECT_ATTEMPT_TIMEOUT_MS into the shared lib/with-timeout.ts
(previously local to use-gateway-boot.ts) so every call site uses the same
budget instead of duplicating the constant.

Regression tests mirror the existing use-gateway-boot.test.tsx hang-repro
pattern: wedge getConnection()/getConnectionFor() with a never-resolving
promise, advance fake timers past the 20s bound, assert the caller settles
instead of hanging. Mutation-verified: reverted the production fix (kept
tests) and confirmed the 6 new tests fail — 4 by genuinely timing out at the
vitest level, 2 by TypeError on the not-yet-exported activeConnection —
restored the fix and confirmed all 70 tests across the gateway/voice/boot/
plugins suites pass, with tsc -p . --noEmit clean throughout.
2026-08-26 21:38:00 -07:00

68 lines
2.3 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
}
/** 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)
}
)
})
}