Files
hermes-agent/apps/desktop/src/api/plugins.test.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

50 lines
1.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { setApiRequestConnection, setApiRequestProfile } from '@/hermes'
import { activeConnection } from './plugins'
// desktop.getConnection/getConnectionFor are IPC round-trips into the main
// process with no timeout of their own (#93454). A wedged main-process
// round-trip must reject instead of hanging pluginSocket's connect() forever.
describe('activeConnection connection timeout (#93454)', () => {
afterEach(() => {
setApiRequestConnection(null)
setApiRequestProfile(null)
Reflect.deleteProperty(window, 'hermesDesktop')
vi.useRealTimers()
})
it('rejects instead of hanging forever when getConnection() wedges', async () => {
vi.useFakeTimers()
setApiRequestProfile('coder')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { getConnection: vi.fn(() => new Promise(() => undefined)) }
})
const pending = expect(activeConnection()).rejects.toThrow('Timed out connecting to profile "coder"')
await vi.advanceTimersByTimeAsync(20_000)
await pending
})
it('rejects instead of hanging forever when getConnectionFor() wedges', async () => {
vi.useFakeTimers()
setApiRequestConnection('gw-tailscale')
setApiRequestProfile('research')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
getConnection: vi.fn(() => new Promise(() => undefined)),
getConnectionFor: vi.fn(() => new Promise(() => undefined))
}
})
const pending = expect(activeConnection()).rejects.toThrow('Timed out connecting to profile "research"')
await vi.advanceTimersByTimeAsync(20_000)
await pending
})
})