Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000
export class HermesGateway extends JsonRpcGatewayClient {
constructor() {
super({
// boot-failure-reauth.ts recognizes this canonical text and routes the
// failure overlay to the remote Gateway sign-in recovery path.
authRejectedErrorMessage:
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
closedErrorMessage: 'Hermes gateway connection closed',
connectErrorMessage: 'Could not connect to Hermes gateway',
createRequestId: nextId => nextId,
Expand Down
245 changes: 231 additions & 14 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $desktopBoot } from '@/store/boot'
import type * as BootStore from '@/store/boot'
import { $desktopBoot, completeDesktopBoot } from '@/store/boot'
import { closeSecondaryGateways, isActivePrimary } from '@/store/gateway'
import { reconnectGateway } from '@/store/gateway-reconnect'
import { $activeGatewayProfile, $profiles, ensureGatewayProfile } from '@/store/profile'
Expand All @@ -10,6 +11,15 @@ import { $connection, $currentCwd, $gatewayState } from '@/store/session'
import { takeGatewaySurvivor } from './gateway-hmr-survivor'
import { useGatewayBoot } from './use-gateway-boot'

vi.mock('@/store/boot', async importOriginal => {
const actual = await importOriginal<typeof BootStore>()

return {
...actual,
completeDesktopBoot: vi.fn(actual.completeDesktopBoot)
}
})

// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
// bug that drives the REAL useGatewayBoot hook + REAL HermesGateway through a
// fake WebSocket we fully control. No Docker / no real port: from the desktop's
Expand All @@ -25,27 +35,40 @@ type Listener = (ev: unknown) => void
let connectionApplied: null | (() => void) = null

// Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect()
// touches: readyState, add/removeEventListener('open'|'error'|'close'), close().
// touches: readyState, add/removeEventListener, close(), and readiness frames.
class FakeWebSocket {
static OPEN = 1
static CLOSED = 3
// Flipped by the test: 'open' = next socket connects; 'fail' = next socket
// errors (a dead remote). Mirrors a VPS going away after the first connect.
static mode: 'open' | 'fail' = 'open'
// Flipped by the test: 'open' emits transport open + gateway.ready; 'fail'
// errors (a dead remote); 'reject' opens then closes during the handshake.
static mode: 'fail' | 'open' | 'reject' = 'open'
static rejectCode = 4401
static instances: FakeWebSocket[] = []

readyState = 0
private listeners: Record<string, Set<Listener>> = {}

constructor(public url: string) {
FakeWebSocket.instances.push(this)
const willOpen = FakeWebSocket.mode === 'open'
// Resolve on the next microtask/macrotask so connect()'s promise wiring is
// in place before open/error fires (matches real async socket handshake).
const mode = FakeWebSocket.mode
// Resolve on the next task so connect()'s promise wiring is in place before
// open/error/close/readiness fires (matches the real async handshake).
setTimeout(() => {
if (willOpen) {
if (mode === 'open') {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})
this.emit('message', {
data: JSON.stringify({
jsonrpc: '2.0',
method: 'event',
params: { type: 'gateway.ready' }
})
})
} else if (mode === 'reject') {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})
this.readyState = FakeWebSocket.CLOSED
this.emit('close', { code: FakeWebSocket.rejectCode })
} else {
this.readyState = FakeWebSocket.CLOSED
this.emit('error', {})
Expand All @@ -63,13 +86,13 @@ class FakeWebSocket {

close() {
this.readyState = FakeWebSocket.CLOSED
this.emit('close', {})
this.emit('close', { code: 1005 })
}

// Force-drop an open socket, as a sleeping laptop / restarted remote would.
drop() {
this.readyState = FakeWebSocket.CLOSED
this.emit('close', {})
this.emit('close', { code: 1006 })
}

private emit(type: string, ev: unknown) {
Expand Down Expand Up @@ -132,14 +155,21 @@ function fakeDesktop() {

function Harness({
beforeConnectionSwitch = () => undefined,
onConnectionReady = () => undefined,
refreshHermesConfig = async () => undefined,
refreshSessions
}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise<void> } = {}) {
}: {
beforeConnectionSwitch?: () => void
onConnectionReady?: (connection: Parameters<Parameters<typeof useGatewayBoot>[0]['onConnectionReady']>[0]) => void
refreshHermesConfig?: () => Promise<void>
refreshSessions?: () => Promise<void>
} = {}) {
useGatewayBoot({
beforeConnectionSwitch,
handleGatewayEvent: () => undefined,
onConnectionReady: () => undefined,
onConnectionReady,
onGatewayReady: () => undefined,
refreshHermesConfig: async () => undefined,
refreshHermesConfig,
refreshSessions: refreshSessions ?? (async () => undefined)
})

Expand All @@ -166,6 +196,7 @@ beforeEach(() => {
$profiles.set([])
vi.useFakeTimers()
FakeWebSocket.mode = 'open'
FakeWebSocket.rejectCode = 4401
FakeWebSocket.instances = []
connectionApplied = null
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
Expand All @@ -181,6 +212,7 @@ beforeEach(() => {
timestamp: Date.now(),
visible: true
})
vi.mocked(completeDesktopBoot).mockClear()
})

afterEach(() => {
Expand Down Expand Up @@ -225,6 +257,16 @@ async function advanceBackoff() {
})
}

function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void

const promise = new Promise<T>(next => {
resolve = next
})

return { promise, resolve }
}

describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => {
it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => {
// The report's actual path: a fresh launch pointed at an unreachable VPS.
Expand Down Expand Up @@ -635,4 +677,179 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
await advanceBackoff()
expect(desktop.getConnection).toHaveBeenCalledTimes(1)
})

it('a 4401 boot handshake fails with the canonical reauth message and never retries', async () => {
const desktop = fakeDesktop()
desktop.getBootProgress = vi.fn(async () => ({
error: 'transient remote failure',
fakeMode: false,
message: 'Desktop boot failed: transient remote failure',
phase: 'backend.error',
progress: 24,
retryable: true,
running: false,
timestamp: Date.now()
}))
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
FakeWebSocket.mode = 'reject'
FakeWebSocket.rejectCode = 4401

render(<Harness />)
await flushAsync()

expect($desktopBoot.get().error).toBe(
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.'
)
expect(desktop.getConnection).toHaveBeenCalledTimes(1)
expect(FakeWebSocket.instances).toHaveLength(1)

await advanceBackoff()

expect(desktop.getConnection).toHaveBeenCalledTimes(1)
expect(FakeWebSocket.instances).toHaveLength(1)
})

it('a 4403 boot handshake fails generically and never retries', async () => {
const desktop = fakeDesktop()
desktop.getBootProgress = vi.fn(async () => ({
error: 'transient remote failure',
fakeMode: false,
message: 'Desktop boot failed: transient remote failure',
phase: 'backend.error',
progress: 24,
retryable: true,
running: false,
timestamp: Date.now()
}))
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
FakeWebSocket.mode = 'reject'
FakeWebSocket.rejectCode = 4403

render(<Harness />)
await flushAsync()

expect($desktopBoot.get().error).toBe('Could not connect to Hermes gateway')
expect($desktopBoot.get().error).not.toMatch(/remote gateway session has expired/i)
expect(desktop.getConnection).toHaveBeenCalledTimes(1)
expect(FakeWebSocket.instances).toHaveLength(1)

await advanceBackoff()

expect(desktop.getConnection).toHaveBeenCalledTimes(1)
expect(FakeWebSocket.instances).toHaveLength(1)
})

it('a soft switch prevents a pending older boot from publishing, adopting, or completing', async () => {
const desktop = fakeDesktop()

const staleBootConnection = {
authMode: 'token' as const,
baseUrl: 'https://stale.example.com',
profile: 'stale',
token: 'stale',
wsUrl: 'wss://stale.example.com/api/ws?token=stale'
}

const switchConnection = {
authMode: 'token' as const,
baseUrl: 'https://switch.example.com',
profile: 'switch',
token: 'switch',
wsUrl: 'wss://switch.example.com/api/ws?token=switch'
}

const pendingBootConnection = deferred<typeof staleBootConnection>()

desktop.getConnection = vi
.fn()
.mockImplementationOnce(() => pendingBootConnection.promise)
.mockResolvedValue(switchConnection) as unknown as typeof desktop.getConnection
desktop.profile.get = vi.fn(async () => ({ profile: 'switch' }))
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop

const onConnectionReady = vi.fn()
const refreshHermesConfig = vi.fn(async () => undefined)
const refreshSessions = vi.fn(async () => undefined)

render(
<Harness
onConnectionReady={onConnectionReady}
refreshHermesConfig={refreshHermesConfig}
refreshSessions={refreshSessions}
/>
)
await flushAsync()

expect(desktop.getConnection).toHaveBeenCalledTimes(1)
expect(connectionApplied).not.toBeNull()

act(() => connectionApplied?.())
await flushAsync()
await flushAsync()

expect($connection.get()).toEqual(switchConnection)
expect(onConnectionReady).toHaveBeenCalledTimes(1)
expect(desktop.profile.get).toHaveBeenCalledTimes(1)
expect(refreshHermesConfig).toHaveBeenCalledTimes(1)
expect(refreshSessions).toHaveBeenCalledTimes(1)
expect(completeDesktopBoot).toHaveBeenCalledTimes(1)

await act(async () => {
pendingBootConnection.resolve(staleBootConnection)
await vi.advanceTimersByTimeAsync(0)
})

expect($connection.get()).toEqual(switchConnection)
expect(onConnectionReady).toHaveBeenCalledTimes(1)
expect(desktop.profile.get).toHaveBeenCalledTimes(1)
expect(refreshHermesConfig).toHaveBeenCalledTimes(1)
expect(refreshSessions).toHaveBeenCalledTimes(1)
expect(completeDesktopBoot).toHaveBeenCalledTimes(1)
expect(FakeWebSocket.instances.some(socket => socket.url === staleBootConnection.wsUrl)).toBe(false)
})

it('a queued retry from an older boot generation cannot restart after a soft switch', async () => {
const desktop = fakeDesktop()
desktop.getBootProgress = vi.fn(async () => ({
error: 'Could not verify the existing SSH backend.',
fakeMode: false,
message: 'Desktop boot failed: Could not verify the existing SSH backend.',
phase: 'backend.error',
progress: 24,
retryable: true,
running: false,
timestamp: Date.now()
}))
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop

FakeWebSocket.mode = 'fail'
render(<Harness />)
await flushAsync()

expect(desktop.getConnection).toHaveBeenCalledTimes(1)
expect(FakeWebSocket.instances).toHaveLength(1)
expect($desktopBoot.get().error).toBeNull()

// Model a timer callback already queued when softSwitch clears its handle.
// The generation check in the callback remains the final ownership gate.
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => undefined)

try {
FakeWebSocket.mode = 'open'
act(() => connectionApplied?.())
await flushAsync()
await flushAsync()

expect($gatewayState.get()).toBe('open')
expect(desktop.getConnection).toHaveBeenCalledTimes(2)
expect(FakeWebSocket.instances).toHaveLength(2)

await advanceBackoff()

expect(desktop.getConnection).toHaveBeenCalledTimes(2)
expect(FakeWebSocket.instances).toHaveLength(2)
} finally {
clearTimeoutSpy.mockRestore()
}
})
})
Loading
Loading