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 <drmani215@gmail.com>
This commit is contained in:
Hermes Agent
2026-09-25 11:37:04 -05:00
committed by brooklyn!
parent 3e11956c15
commit 7be5d40033
2 changed files with 121 additions and 6 deletions

View File

@@ -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<void>>()
.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<void>>().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<void>>()
.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<void>>().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)
})
})

View File

@@ -164,6 +164,51 @@ export function primaryRuntimeConnectionId(connection: Pick<HermesConnection, 'c
return connection.mode === 'local' ? 'local' : null
}
// A freshly spawned backend can block its event loop for 15-30s while it
// connects MCP servers and discovers plugins, so a single initial connect
// attempt races backend cold-start and loses intermittently — the renderer
// surfaced "Could not connect to Hermes gateway" even though the backend
// became healthy moments later (#49645). Retry the initial dial, re-minting
// the WS URL on every attempt (OAuth tickets are single-use), instead of
// failing the whole boot on the first transport error. Reauth failures
// propagate immediately: more attempts with a dead ticket can never
// succeed. Exported for tests.
export async function connectInitialGateway({
attempts = 8,
connect,
delayMs = 3_000,
isCancelled
}: {
attempts?: number
connect: () => Promise<void>
delayMs?: number
isCancelled: () => boolean
}): Promise<void> {
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) {