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