From 7be5d400337f438d39201ba457f8d5506e25fa11 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 25 Sep 2026 11:37:04 -0500 Subject: [PATCH] fix(desktop): retry the initial gateway WS connect across backend cold-start The renderer made a single gateway.connect() call during boot, so a freshly spawned backend that was still initializing (event loop blocked 15-30s by MCP connects and plugin discovery) made the one dial lose and boot ended in the 'Could not connect to Hermes gateway' modal even though the backend became healthy moments later. Retry the initial dial with bounded attempts, re-minting the WS URL on every attempt (OAuth tickets are single-use), and propagate reauth failures immediately. Co-authored-by: Mani Saint-Victor, MD --- .../use-gateway-boot-initial-connect.test.ts | 54 ++++++++++++++ .../src/app/gateway/hooks/use-gateway-boot.ts | 73 +++++++++++++++++-- 2 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/gateway/hooks/use-gateway-boot-initial-connect.test.ts diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot-initial-connect.test.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot-initial-connect.test.ts new file mode 100644 index 0000000000..16674b8737 --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot-initial-connect.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +import { GatewayReauthRequiredError } from '@hermes/shared' + +import { connectInitialGateway } from './use-gateway-boot' + +const never = () => false + +describe('connectInitialGateway (initial-boot dial retry, #49645)', () => { + it('retries past transient transport failures and resolves once a connect succeeds', async () => { + const connect = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('WebSocket connection closed (1006)')) + .mockRejectedValueOnce(new Error('connect ECONNREFUSED')) + .mockResolvedValueOnce(undefined) + + await expect(connectInitialGateway({ connect, delayMs: 0, isCancelled: never })).resolves.toBeUndefined() + expect(connect).toHaveBeenCalledTimes(3) + }) + + it('throws the last error after exhausting all attempts', async () => { + const connect = vi.fn<() => Promise>().mockRejectedValue(new Error('backend cold')) + + await expect( + connectInitialGateway({ attempts: 3, connect, delayMs: 0, isCancelled: never }) + ).rejects.toThrow('backend cold') + expect(connect).toHaveBeenCalledTimes(3) + }) + + it('fails fast on reauth errors without retrying', async () => { + const connect = vi + .fn<() => Promise>() + .mockRejectedValue(new GatewayReauthRequiredError('sign in again')) + + await expect(connectInitialGateway({ connect, delayMs: 0, isCancelled: never })).rejects.toBeInstanceOf( + GatewayReauthRequiredError + ) + expect(connect).toHaveBeenCalledTimes(1) + }) + + it('stops retrying once cancelled (component unmounted)', async () => { + let cancelled = false + const connect = vi.fn<() => Promise>().mockImplementation(() => { + cancelled = true + + return Promise.reject(new Error('connect ECONNREFUSED')) + }) + + await expect( + connectInitialGateway({ connect, delayMs: 0, isCancelled: () => cancelled }) + ).rejects.toThrow('connect ECONNREFUSED') + expect(connect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 58e5449f4b..399b5cd7fc 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -164,6 +164,51 @@ export function primaryRuntimeConnectionId(connection: Pick Promise + delayMs?: number + isCancelled: () => boolean +}): Promise { + let lastConnectError: unknown = null + + for (let attempt = 0; attempt < attempts && !isCancelled(); attempt += 1) { + try { + await connect() + lastConnectError = null + + break + } catch (err) { + if (isGatewayReauthRequired(err)) { + throw err + } + lastConnectError = err + + if (attempt < attempts - 1) { + await new Promise(resolve => setTimeout(resolve, delayMs)) + } + } + } + + if (lastConnectError) { + throw lastConnectError + } +} + interface GatewayBootOptions { beforeConnectionSwitch: () => void handleGatewayEvent: (event: GatewayEvent) => void @@ -1345,12 +1390,15 @@ export function useGatewayBoot({ console.warn('Failed to seed default workspace cwd pre-connect', err) } - // Mint a fresh WS URL right before connecting. For OAuth gateways the - // ticket is single-use with a short TTL, so the ticket baked into + // Mint a fresh WS URL once to classify the boot boundary: a valid + // WebSocket dial against a REMOTE descriptor is the only failure that + // counts as a transient renderer-side dial (#82679). URL and capability + // failures stay terminal at their own boundaries. For OAuth gateways + // the ticket is single-use with a short TTL, so the ticket baked into // conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it rather than - // connecting with a dead ticket. Auth rejection asks for sign-in. This - // await is bounded like the reconnect path (#93454) so a wedged mint - // reaches the recovery affordance instead of hanging "Starting Hermes…". + // connecting with a dead ticket. This await is bounded like the + // reconnect path (#93454) so a wedged mint reaches the recovery + // affordance instead of hanging "Starting Hermes…". const wsUrl = await withTimeout( resolveDesktopGatewayWsUrl(desktop, conn), RECONNECT_ATTEMPT_TIMEOUT_MS, @@ -1364,7 +1412,20 @@ export function useGatewayBoot({ stage = 'dialing' } - await gateway.connect(wsUrl) + // Retry the initial dial across backend cold-start (#49645). The WS URL + // is re-minted on EVERY attempt: OAuth tickets are single-use, so the + // first attempt's ticket is dead by the time a retry runs. + await connectInitialGateway({ + connect: async () => { + const attemptWsUrl = await withTimeout( + resolveDesktopGatewayWsUrl(desktop, conn), + RECONNECT_ATTEMPT_TIMEOUT_MS, + 'Timed out minting the gateway WebSocket URL' + ) + await gateway.connect(attemptWsUrl) + }, + isCancelled: () => cancelled + }) stage = 'connected' if (cancelled) {