import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopConnectionsRegistry } from '@/global' import { createClientSessionState } from '@/lib/chat-runtime' import { $desktopBoot } from '@/store/boot' import { $connectionsRegistry, _resetConnectionsForTests, selectConnection, setConnectionsRegistry } from '@/store/connections' import { activeGateway, closeSecondaryGateways, disposeSecondariesForConnection, ensureGatewayForAgent, isActivePrimary, requestGatewayForAgent } from '@/store/gateway' import { reconnectGateway } from '@/store/gateway-reconnect' import { $gatewaySwitching, beginGatewaySwitch, endGatewaySwitch, recoverActiveSourceAfterFailedGatewaySwitch } from '@/store/gateway-switch' import { notifyError } from '@/store/notifications' import { $activeGatewayProfile, $profiles, ensureGatewayProfile } from '@/store/profile' import { $activeSessionId, $awaitingResponse, $busy, $connection, $currentCwd, $gatewayState, $selectedStoredSessionId, $sessionsLoading, getConfiguredDefaultProjectDir, setActiveSessionId, setSelectedStoredSessionId } from '@/store/session' import { $sessionTiles, $workingSessionIds, clearAllSessionStates, publishSessionState } from '@/store/session-states' import { deferred } from '../../../test/deferred' import { takeGatewaySurvivor } from './gateway-hmr-survivor' import { primaryRuntimeConnectionId, useGatewayBoot } from './use-gateway-boot' vi.mock(import('@/store/notifications'), async importOriginal => ({ ...(await importOriginal()), notifyError: vi.fn() })) // 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 // point of view a "remote VPS" is just a WebSocket that opens once and later // refuses to reopen, so that is exactly (and only) what we fake. // // The previous test (gateway-connecting-overlay.test.tsx) hand-set the stores // and asserted the overlays; this one proves the HOOK actually PRODUCES that // stuck store combo — closing the "inferred by reading code" gap on the // post-boot reconnect loop. type Listener = (ev: unknown) => void let connectionApplied: null | (() => void) = null let powerResume: null | (() => void) = null describe('primaryRuntimeConnectionId', () => { it('uses the registry identity when the primary connection has one', () => { expect(primaryRuntimeConnectionId({ connectionId: ' tower ', mode: 'remote' })).toBe('tower') }) it('uses the stable local identity for an app-managed backend', () => { expect(primaryRuntimeConnectionId({ mode: 'local' })).toBe('local') }) it('returns null for an unknown remote identity so the caller falls back to live-connection scoping', () => { expect(primaryRuntimeConnectionId({ mode: 'remote' })).toBeNull() }) }) // Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect() // touches: readyState, add/removeEventListener('open'|'error'|'close'), close(). 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' static instances: FakeWebSocket[] = [] // Ping behavior: 'pong' answers with a healthy pong frame; 'silent' swallows // the request (the half-open-socket simulation — connection looks OPEN but // every RPC hangs until its per-call timeout); 'method-not-found' answers // the JSON-RPC error a PRE-ping backend returns (a healthy, version-skewed // response that must NOT trigger a reconnect). static pingMode: 'pong' | 'silent' | 'method-not-found' = 'pong' readyState = 0 private listeners: Record> = {} 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). setTimeout(() => { if (willOpen) { this.readyState = FakeWebSocket.OPEN this.emit('open', {}) } else { this.readyState = FakeWebSocket.CLOSED this.emit('error', {}) } }, 0) } addEventListener(type: string, fn: Listener) { ;(this.listeners[type] ??= new Set()).add(fn) } removeEventListener(type: string, fn: Listener) { this.listeners[type]?.delete(fn) } close() { this.readyState = FakeWebSocket.CLOSED this.emit('close', {}) } // Force-drop an open socket, as a sleeping laptop / restarted remote would. drop() { this.readyState = FakeWebSocket.CLOSED this.emit('close', {}) } send(data: string) { let frame: { id?: unknown; method?: string } try { frame = JSON.parse(data) as { id?: unknown; method?: string } } catch { return } if (frame.method !== 'ping') { return } if (FakeWebSocket.pingMode === 'pong') { this.emit('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, result: { pong: true } }) }) } else if (FakeWebSocket.pingMode === 'method-not-found') { this.emit('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, error: { code: -32601, message: 'Method not found' } }) }) } // 'silent': swallow — a healthy socket answers, a half-open one never does. } private emit(type: string, ev: unknown) { for (const fn of this.listeners[type] ?? []) { fn(ev) } } } const primaryConn = { authMode: 'token' as const, baseUrl: 'https://vps.example.com', connectionId: 'primary-vps', profile: 'default', token: 't', wsUrl: 'wss://vps.example.com/api/ws?token=t' } const coderConn = { authMode: 'token' as const, baseUrl: 'https://coder.example.com', connectionId: 'coder-remote', profile: 'coder', token: 'c', wsUrl: 'wss://coder.example.com/api/ws?token=c' } function fakeDesktop() { let bootProgressHandler: ((payload: Record) => void) | null = null return { getConnection: vi.fn(async (profile?: null | string) => { const key = (profile ?? '').trim() return !key || key === 'default' ? primaryConn : coderConn }), getGatewayWsUrl: vi.fn(async (conn?: { wsUrl?: string }) => conn?.wsUrl ?? primaryConn.wsUrl), getBootProgress: vi.fn(async () => ({ error: null as null | string, fakeMode: false, message: '', phase: 'init', progress: 0, retryable: false as boolean, running: true as boolean, timestamp: Date.now() })), onBootProgress: vi.fn(callback => { bootProgressHandler = callback return () => { bootProgressHandler = null } }), // Test helper: fire a post-boot progress event through the real subscription. emitBootProgress(payload: Record) { bootProgressHandler?.(payload) }, onBackendExit: vi.fn(() => () => undefined), onConnectionApplied: vi.fn(callback => { connectionApplied = callback return () => { connectionApplied = null } }), onPowerResume: vi.fn(callback => { powerResume = callback return () => { powerResume = null } }), revalidateConnection: vi.fn(async () => ({ ok: true, rebuilt: false })), onWindowStateChanged: vi.fn(() => () => undefined), touchBackend: vi.fn(async () => undefined), profile: { get: vi.fn(async () => ({ profile: 'default' })) } } } function Harness({ beforeConnectionSwitch = () => undefined, refreshHermesConfig = async () => undefined, refreshSessions }: { beforeConnectionSwitch?: () => void refreshHermesConfig?: (force?: boolean, shouldPublish?: () => boolean) => Promise refreshSessions?: (shouldPublish?: () => boolean) => Promise } = {}) { useGatewayBoot({ beforeConnectionSwitch, handleGatewayEvent: () => undefined, onConnectionReady: () => undefined, onGatewayReady: () => undefined, refreshHermesConfig, refreshSessions: refreshSessions ?? (async () => undefined) }) return null } const originalWebSocket = globalThis.WebSocket beforeEach(() => { // Drop any parked gateway left by a prior file/case (globalThis slot). const leftover = takeGatewaySurvivor() if (leftover) { try { leftover.gateway.close() } catch { // ignore } } closeSecondaryGateways() $activeGatewayProfile.set('default') $connection.set(null) $profiles.set([]) $sessionTiles.set([]) vi.useFakeTimers() FakeWebSocket.mode = 'open' FakeWebSocket.instances = [] FakeWebSocket.pingMode = 'pong' connectionApplied = null powerResume = null vi.mocked(notifyError).mockReset() ;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop() $gatewayState.set('idle') $busy.set(false) $awaitingResponse.set(false) $desktopBoot.set({ error: null, fakeMode: false, message: '', phase: 'init', progress: 0, running: true, timestamp: Date.now(), visible: true }) }) afterEach(() => { cleanup() // Vitest keeps import.meta.hot truthy, so the boot effect's cleanup parks an // open gateway instead of tearing it down (the real HMR path). Drain + close // that survivor so the next test boots a fresh socket instead of adoptBoot(). const survivor = takeGatewaySurvivor() if (survivor) { try { survivor.gateway.close() } catch { // ignore } } closeSecondaryGateways() $activeGatewayProfile.set('default') $connection.set(null) $profiles.set([]) $sessionTiles.set([]) _resetConnectionsForTests() $connectionsRegistry.set(null) setActiveSessionId(null) setSelectedStoredSessionId(null) endGatewaySwitch() vi.useRealTimers() ;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket delete (window as { hermesDesktop?: unknown }).hermesDesktop window.localStorage.removeItem('hermes.desktop.workspace-cwd') $currentCwd.set('') $busy.set(false) $awaitingResponse.set(false) }) // Let pending microtasks (awaits) AND the queued 0ms socket open/error fire. async function flushAsync() { await act(async () => { await vi.advanceTimersByTimeAsync(0) }) } // Drive the exponential backoff forward by its full cap so the next scheduled // reconnect attempt actually runs (1s,2s,4s,8s,15s,15s…). Returns after the // attempt's async work settles. async function advanceBackoff() { await act(async () => { await vi.advanceTimersByTimeAsync(15_000) }) } 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. // startHermes()'s remote branch awaits waitForHermes() for 45s before it // throws, so the renderer's `await desktop.getConnection()` stays pending // that whole window. During it: gatewayState is still 'idle' (connect was // never reached) and boot.error is null → connecting=true → the fullscreen // CONNECTING overlay, latched, blocking Settings. let rejectConn: (e: Error) => void = () => undefined const desktop = fakeDesktop() desktop.getConnection = vi.fn( () => new Promise((_resolve, reject) => { rejectConn = reject }) ) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() // getConnection is still pending — the dead-VPS wait. No socket was ever // created, gatewayState never left idle, boot.error is null. expect(FakeWebSocket.instances).toHaveLength(0) expect($gatewayState.get()).not.toBe('open') expect($desktopBoot.get().error).toBeNull() // ^ connecting === true here → fullscreen CONNECTING, no Settings. // After ~45s waitForHermes gives up and getConnection rejects → boot() // catch → failDesktopBoot → the BootFailureOverlay recovery surface. await act(async () => { rejectConn(new Error('Hermes backend did not become ready: timeout')) await vi.advanceTimersByTimeAsync(0) }) expect($desktopBoot.get().error).toBeTruthy() }) it('resets the old machine context before connecting an applied gateway', async () => { const beforeConnectionSwitch = vi.fn() render() await flushAsync() expect(connectionApplied).not.toBeNull() act(() => connectionApplied?.()) expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1) await flushAsync() expect($gatewayState.get()).toBe('open') }) it('a stale failed Settings switch cannot publish failure or disarm the newer switch owner', async () => { const desktop = fakeDesktop() ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') const failureA = new Error('switch A failed') const failureB = new Error('switch B failed') let rejectA: (error: Error) => void = () => undefined let rejectB: (error: Error) => void = () => undefined desktop.getConnection .mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectA = reject }) ) .mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectB = reject }) ) act(() => connectionApplied?.()) expect($gatewaySwitching.get()).toBe(true) expect($sessionsLoading.get()).toBe(true) act(() => connectionApplied?.()) expect($gatewaySwitching.get()).toBe(true) expect($sessionsLoading.get()).toBe(true) await act(async () => { rejectA(failureA) await vi.advanceTimersByTimeAsync(0) }) expect(notifyError).not.toHaveBeenCalled() expect($desktopBoot.get().error).toBeNull() expect($gatewaySwitching.get()).toBe(true) expect($sessionsLoading.get()).toBe(true) await act(async () => { rejectB(failureB) await vi.advanceTimersByTimeAsync(0) }) expect(notifyError).toHaveBeenCalledTimes(1) expect(notifyError).toHaveBeenCalledWith(failureB, expect.any(String)) expect($desktopBoot.get().error).toBe(failureB.message) expect($gatewaySwitching.get()).toBe(false) expect($sessionsLoading.get()).toBe(false) }) it('does not publish a late Settings failure after a newer switch wins', async () => { const desktop = fakeDesktop() let rejectStale: (error: Error) => void = () => undefined ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() desktop.getConnection.mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectStale = reject }) ) act(() => connectionApplied?.()) await vi.waitFor(() => expect(desktop.getConnection).toHaveBeenCalledTimes(2)) act(() => connectionApplied?.()) await flushAsync() await flushAsync() expect($gatewaySwitching.get()).toBe(false) expect($desktopBoot.get().error).toBeNull() await act(async () => { rejectStale(new Error('late stale failure')) await vi.advanceTimersByTimeAsync(0) }) expect($desktopBoot.get().error).toBeNull() expect(notifyError).not.toHaveBeenCalled() }) it('reports a Settings switch setup failure and does not disarm a newer switch started by recovery UI', async () => { const failure = new Error('machine-context reset failed') const beforeConnectionSwitch = vi.fn() let newerToken: null | ReturnType = null beforeConnectionSwitch.mockImplementationOnce(() => { throw failure }) vi.mocked(notifyError).mockImplementationOnce((_error, fallback) => { // A notification/recovery callback may synchronously start another // switch. The failed Settings attempt never received a token and must // not force this newer owner's barrier down from its finally block. newerToken = beginGatewaySwitch() return fallback }) render() await flushAsync() expect($gatewayState.get()).toBe('open') act(() => connectionApplied?.()) await flushAsync() expect($desktopBoot.get().error).toBe(failure.message) expect(notifyError).toHaveBeenCalledWith(failure, expect.any(String)) expect(newerToken).not.toBeNull() expect($gatewaySwitching.get()).toBe(true) expect($sessionsLoading.get()).toBe(true) endGatewaySwitch(newerToken ?? undefined) }) it('a token-less setup failure cannot publish after a nested switch raised a newer barrier', async () => { const failure = new Error('outer setup failed') const beforeConnectionSwitch = vi.fn() let newerToken: null | ReturnType = null beforeConnectionSwitch.mockImplementationOnce(() => { newerToken = beginGatewaySwitch() throw failure }) render() await flushAsync() act(() => connectionApplied?.()) await flushAsync() expect(newerToken).not.toBeNull() expect($gatewaySwitching.get()).toBe(true) expect($desktopBoot.get().error).toBeNull() expect(notifyError).not.toHaveBeenCalled() endGatewaySwitch(newerToken ?? undefined) }) it('a store-driven switch (Sessions switcher) runs the same machine-context reset as a Settings apply (#93937)', async () => { const beforeConnectionSwitch = vi.fn() const { unmount } = render() await flushAsync() act(() => beginGatewaySwitch()) expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1) expect($gatewaySwitching.get()).toBe(true) act(() => endGatewaySwitch()) expect($gatewaySwitching.get()).toBe(false) // Teardown unregisters: a switch after unmount must not call a dead host. unmount() beginGatewaySwitch() endGatewaySwitch() expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1) }) it("#93937: the Sessions switcher never publishes the new source while the previous backend's runtime id is still bound", async () => { // Real stores end to end: real useGatewayBoot, real gateway registry, real // selectConnection, fake sockets. Boot on the primary VPS with a transcript // open (its runtime id was minted by THAT backend), then switch sources // through the sidebar door. Before the fix that door activated the new // socket first and wiped the bindings after an IPC round-trip, so the // renderer sat on "gateway B + runtime id from A" and B answered every // session RPC with "session not found". const registryConnections: DesktopConnectionsRegistry = { connections: [ { id: 'primary-vps', kind: 'remote', label: 'VPS', tokenPreview: '...t', tokenSet: true }, { id: 'coder-remote', kind: 'remote', label: 'Coder', tokenPreview: '...c', tokenSet: true } ], primary: 'primary-vps', secureTokenStorage: true, version: 2 } const desktop = fakeDesktop() as ReturnType & Record const setLastUsed = vi.fn(async (id: string) => ({ ok: true, registry: { ...registryConnections, lastUsed: id } })) let bindingAtDial: null | string = null desktop.api = vi.fn(async ({ path }: { path: string }) => path === '/api/profiles/active' ? { active: 'default', current: 'default' } : { profiles: [] } ) desktop.getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ ...coderConn, connectionId, profile, registryScoped: true })) desktop.getGatewayWsUrlFor = vi.fn(async () => { // Phase 1 (the dial) runs with the previous source still fully bound. bindingAtDial = $activeSessionId.get() return coderConn.wsUrl }) desktop.connections = { list: vi.fn(async () => registryConnections), setLastUsed } ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop const beforeConnectionSwitch = vi.fn() render() await flushAsync() expect($gatewayState.get()).toBe('open') expect($connection.get()?.connectionId).toBe('primary-vps') setConnectionsRegistry(registryConnections) setSelectedStoredSessionId('stored-on-vps') setActiveSessionId('a93bb39d') // Every instant the new source is visible, with what a session-scoped // effect would read right then. const published: Array<{ activeSessionId: null | string; switching: boolean }> = [] const off = $connection.listen(next => { if (next?.connectionId === 'coder-remote') { published.push({ activeSessionId: $activeSessionId.get(), switching: $gatewaySwitching.get() }) } }) const switching = selectConnection('coder-remote') await flushAsync() await flushAsync() await flushAsync() await switching off() // The previous backend's runtime id was already gone — and the barrier up — // at every publication of the new source. (Pre-fix: the first publication // carried activeSessionId 'a93bb39d' with the barrier down.) expect(published.length).toBeGreaterThan(0) expect(published).toEqual(published.map(() => ({ activeSessionId: null, switching: true }))) expect(bindingAtDial).toBe('a93bb39d') expect(beforeConnectionSwitch).toHaveBeenCalledTimes(1) expect($connection.get()?.connectionId).toBe('coder-remote') expect(isActivePrimary()).toBe(false) expect($activeSessionId.get()).toBeNull() expect($selectedStoredSessionId.get()).toBeNull() expect($gatewaySwitching.get()).toBe(false) // The switch committed: the registry remembers the new source as last-used. expect(setLastUsed).toHaveBeenCalledWith('coder-remote') // Publishing the secondary must not relabel the primary socket. Returning // to its source should reuse that socket, not dial the secondary endpoint. const socketsAfterSwitch = FakeWebSocket.instances.length await expect(requestGatewayForAgent('primary-vps', 'default', 'ping')).resolves.toEqual({ pong: true }) expect(FakeWebSocket.instances).toHaveLength(socketsAfterSwitch) }) it('a Settings switch superseded while reading its descriptor cannot publish over a newer Sessions switch', async () => { const registryConnections: DesktopConnectionsRegistry = { connections: [ { id: 'primary-vps', kind: 'remote', label: 'VPS', tokenPreview: '...t', tokenSet: true }, { id: 'coder-remote', kind: 'remote', label: 'Coder', tokenPreview: '...c', tokenSet: true } ], primary: 'primary-vps', secureTokenStorage: true, version: 2 } const desktop = fakeDesktop() as ReturnType & Record const settingsConn = { ...primaryConn, connectionId: 'settings-a', profile: 'settings-profile', wsUrl: 'wss://settings-a.example.com/api/ws?token=a' } let releaseSettings: (connection: typeof settingsConn) => void = () => undefined desktop.api = vi.fn(async ({ path }: { path: string }) => path === '/api/profiles/active' ? { active: 'coder', current: 'coder' } : { profiles: [] } ) desktop.getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ ...coderConn, connectionId, profile, registryScoped: true })) desktop.getGatewayWsUrlFor = vi.fn(async () => coderConn.wsUrl) desktop.connections = { list: vi.fn(async () => registryConnections), setLastUsed: vi.fn(async (id: string) => ({ ok: true, registry: { ...registryConnections, lastUsed: id } })) } ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') setConnectionsRegistry(registryConnections) desktop.getConnection.mockImplementationOnce( () => new Promise(resolve => { releaseSettings = resolve }) ) act(() => connectionApplied?.()) await vi.waitFor(() => expect(desktop.getConnection).toHaveBeenCalledTimes(2)) const sessionsSwitch = selectConnection('coder-remote') await flushAsync() await flushAsync() await flushAsync() await sessionsSwitch expect(isActivePrimary()).toBe(false) expect($activeGatewayProfile.get()).toBe('default') expect($connection.get()?.connectionId).toBe('coder-remote') const wsUrlReads = desktop.getGatewayWsUrl.mock.calls.length const profileReads = desktop.profile.get.mock.calls.length const profileRefreshes = vi.mocked(desktop.api as ReturnType).mock.calls.length const socketCount = FakeWebSocket.instances.length await act(async () => { releaseSettings(settingsConn) await vi.advanceTimersByTimeAsync(0) }) // Switch-token ownership governs every later publication, not just loading // teardown: stale Settings work cannot publish/connect/refresh after B won. expect(isActivePrimary()).toBe(false) expect($activeGatewayProfile.get()).toBe('default') expect($connection.get()?.connectionId).toBe('coder-remote') expect(desktop.getGatewayWsUrl).toHaveBeenCalledTimes(wsUrlReads) expect(desktop.profile.get).toHaveBeenCalledTimes(profileReads) expect(desktop.api).toHaveBeenCalledTimes(profileRefreshes) expect(FakeWebSocket.instances).toHaveLength(socketCount) }) it('passes switch ownership through a session refresh held across a newer switch', async () => { const staleRefresh = deferred() const publications: string[] = [] let switchRefresh = 0 const refreshSessions = vi.fn(async (shouldPublish?: () => boolean) => { // Initial boot remains a compatible zero-argument caller. if (!shouldPublish) { return } switchRefresh += 1 const label = switchRefresh === 1 ? 'settings-a' : 'settings-b' if (switchRefresh === 1) { await staleRefresh.promise } if (shouldPublish()) { publications.push(label) } }) render() await flushAsync() act(() => connectionApplied?.()) await vi.waitFor(() => expect(switchRefresh).toBe(1)) act(() => connectionApplied?.()) await vi.waitFor(() => expect(switchRefresh).toBe(2)) await flushAsync() expect(publications).toEqual(['settings-b']) await act(async () => { staleRefresh.resolve() await vi.advanceTimersByTimeAsync(0) }) expect(publications).toEqual(['settings-b']) expect($gatewaySwitching.get()).toBe(false) }) it('forwards failed-switch recovery ownership through its registered lifecycle', async () => { const refreshSessions = vi.fn(async (_shouldPublish?: () => boolean) => undefined) render() await flushAsync() const failed = beginGatewaySwitch() recoverActiveSourceAfterFailedGatewaySwitch(failed) endGatewaySwitch(failed) await vi.waitFor(() => expect(refreshSessions).toHaveBeenCalledTimes(2)) const shouldPublish = refreshSessions.mock.calls[1][0] expect(shouldPublish).toBeTypeOf('function') expect(shouldPublish?.()).toBe(true) const newer = beginGatewaySwitch() expect(shouldPublish?.()).toBe(false) endGatewaySwitch(newer) }) it('a superseded Settings switch cannot publish delayed cwd or config work after the winner', async () => { const desktop = fakeDesktop() as ReturnType & Record const staleSanitize = deferred<{ cwd: string }>() const staleConfig = deferred() const configPublications: string[] = [] let settingsRead = 0 let sanitizeRead = 0 let switchConfigRead = 0 const settings = { getDefaultProjectDir: vi.fn(async () => { settingsRead += 1 return { defaultLabel: settingsRead === 1 ? '/settings-a' : '/settings-b', dir: settingsRead === 1 ? '/settings-a' : '/settings-b', resolvedCwd: settingsRead === 1 ? '/settings-a' : '/settings-b' } }) } const sanitizeWorkspaceCwd = vi.fn((cwd: string) => { sanitizeRead += 1 return sanitizeRead === 1 ? staleSanitize.promise : Promise.resolve({ cwd }) }) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop const refreshHermesConfig = async (_force = false, shouldPublish?: () => boolean) => { if (!shouldPublish) { return } switchConfigRead += 1 const label = switchConfigRead === 1 ? 'settings-a' : 'settings-b' if (switchConfigRead === 1) { await staleConfig.promise } if (shouldPublish()) { configPublications.push(label) } } render() await flushAsync() expect($gatewayState.get()).toBe('open') desktop.settings = settings desktop.sanitizeWorkspaceCwd = sanitizeWorkspaceCwd act(() => connectionApplied?.()) await vi.waitFor(() => expect(sanitizeWorkspaceCwd).toHaveBeenCalledTimes(1)) act(() => connectionApplied?.()) await flushAsync() await flushAsync() expect($gatewaySwitching.get()).toBe(false) expect(getConfiguredDefaultProjectDir()).toBe('/settings-b') expect($currentCwd.get()).toBe('/settings-b') expect(configPublications).toEqual(['settings-b']) await act(async () => { staleSanitize.resolve({ cwd: '/settings-a/stale' }) staleConfig.resolve() await vi.advanceTimersByTimeAsync(0) }) expect(getConfiguredDefaultProjectDir()).toBe('/settings-b') expect($currentCwd.get()).toBe('/settings-b') expect(configPublications).toEqual(['settings-b']) }) it('publishes the cold-boot primary registry identity for owned session RPCs', async () => { render() await flushAsync() expect($gatewayState.get()).toBe('open') expect(FakeWebSocket.instances).toHaveLength(1) await expect(requestGatewayForAgent('primary-vps', 'default', 'ping')).resolves.toEqual({ pong: true }) expect(FakeWebSocket.instances).toHaveLength(1) }) it('keeps registered source sockets alive during a legacy mode apply', async () => { const desktop = fakeDesktop() as ReturnType & { getConnectionFor: ReturnType } desktop.getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ ...coderConn, connectionId, profile, wsUrl: `wss://${connectionId}.example.com/api/ws?token=r` })) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') let opening!: Promise act(() => { opening = ensureGatewayForAgent('cloud', 'default') }) await flushAsync() await opening const registeredGateway = activeGateway() expect(registeredGateway).not.toBeNull() expect(isActivePrimary()).toBe(false) act(() => connectionApplied?.()) await flushAsync() await flushAsync() // Applying the legacy Local/Cloud mode must not close an independent v2 // source. The foreground returns to the new primary, while the registered // socket remains reusable and cannot arm ws_orphan_reap on the old backend. expect(registeredGateway?.connectionState).toBe('open') expect(isActivePrimary()).toBe(true) }) it('re-fetches the profile rail from the NEW backend after a connection apply (#85731)', async () => { // The reported repro: connected to backend A, the rail shows A's named // profiles; the user applies a different remote/Cloud connection (soft // re-home). The rail must repopulate from backend B — before the fix // nothing deterministically re-pulled /api/profiles on the soft switch, // so the rail kept (or, with a stale in-flight response, collapsed to) // the previous backend's list. const desktop = fakeDesktop() as ReturnType & { api: ReturnType } desktop.api = vi.fn(async ({ path }: { path: string }) => { if (path === '/api/profiles/active') { return { active: 'default', current: 'default' } } if (path === '/api/profiles') { return { profiles: [ { is_default: true, name: 'default' }, { is_default: false, name: 'cloud-eric' } ] } } throw new Error(`unexpected api call: ${path}`) }) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') // The rail currently mirrors backend A's profile universe. $profiles.set([ { is_default: true, name: 'default' }, { is_default: false, name: 'eric' } ] as never) // Settings → Gateway apply lands: main tears down softly and notifies. act(() => connectionApplied?.()) await flushAsync() await flushAsync() expect($gatewayState.get()).toBe('open') // Backend B's list replaced A's — the rail survives the switch instead of // painting the previous backend's (or an empty) universe. expect($profiles.get().map(profile => profile.name)).toEqual(['default', 'cloud-eric']) }) it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => { render() await flushAsync() // Initial boot connected. expect($gatewayState.get()).toBe('open') expect($desktopBoot.get().error).toBeNull() expect(FakeWebSocket.instances).toHaveLength(1) // The remote VPS goes away: drop the live socket, and make every reopen // fail from here on. FakeWebSocket.mode = 'fail' act(() => FakeWebSocket.instances[0].drop()) await flushAsync() // Burn a couple backoff cycles BEFORE the escalation threshold. Socket // down, hook retrying, gatewayState non-open, boot.error still null so // chat stays usable (no CONNECTING / no couldn't-start overlay). await advanceBackoff() expect($gatewayState.get()).not.toBe('open') expect($desktopBoot.get().error).toBeNull() // It is actively retrying, not idle — more sockets were minted. expect(FakeWebSocket.instances.length).toBeGreaterThan(1) }) it('FIX: after a prolonged drop the chat stays unlocked (toast, not boot.error)', async () => { render() await flushAsync() expect($desktopBoot.get().error).toBeNull() FakeWebSocket.mode = 'fail' act(() => FakeWebSocket.instances[0].drop()) await flushAsync() // Walk the backoff well past the historical 45s threshold and into the // current multi-minute escalate window. Chat must stay unlocked either way. for (let i = 0; i < 24; i += 1) { await advanceBackoff() } // Transport blips must NOT take the full-screen "couldn't start" overlay — // users were locked out of reading/drafting for the whole reconnect window. expect($desktopBoot.get().error).toBeNull() expect($gatewayState.get()).not.toBe('open') // Still retrying. expect(FakeWebSocket.instances.length).toBeGreaterThan(1) }) it('FIX: a successful reconnect after a prolonged drop restores the open gateway', async () => { render() await flushAsync() FakeWebSocket.mode = 'fail' act(() => FakeWebSocket.instances[0].drop()) await flushAsync() for (let i = 0; i < 24; i += 1) { await advanceBackoff() } expect($desktopBoot.get().error).toBeNull() // The remote comes back: next reconnect attempt opens. FakeWebSocket.mode = 'open' await advanceBackoff() expect($gatewayState.get()).toBe('open') expect($desktopBoot.get().error).toBeNull() }) it('a getConnection() that hangs on reconnect does not permanently latch the backoff loop (#93454)', async () => { // Repro: a remote gateway drops, the backoff loop kicks off a reconnect, // and the IPC round-trip into main (desktop.getConnection) never settles // — e.g. a wedged revalidation after a liveness-probe trip, even though // the backend itself answers fine. Without an internal timeout on that // await, `reconnecting` never clears and every later // scheduleReconnect()/attemptReconnect() early-returns forever, so the UI // stays stuck until the app is restarted. const desktop = fakeDesktop() const originalGetConnection = desktop.getConnection let callCount = 0 desktop.getConnection = vi.fn((profile?: null | string) => { callCount += 1 // The initial boot call succeeds; every reconnect attempt after the // drop hangs indefinitely. return callCount === 1 ? originalGetConnection(profile) : new Promise(() => undefined) }) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') expect(callCount).toBe(1) act(() => FakeWebSocket.instances[0].drop()) await advanceBackoff() expect(callCount).toBe(2) expect($gatewayState.get()).not.toBe('open') // Advance past the internal reconnect-attempt timeout (20s) — the stalled // await must reject so the `reconnecting` guard clears and the backoff // loop schedules another attempt, instead of latching forever on the // still-pending first hang. await act(async () => { await vi.advanceTimersByTimeAsync(20_000) }) await advanceBackoff() expect(callCount).toBeGreaterThanOrEqual(3) }) it('a revalidateConnection() that hangs on reconnect does not permanently latch the backoff loop (#93454)', async () => { // Same failure mode as the getConnection() repro above, but for the OTHER // unbounded IPC await in the same try block: a wedged revalidation after a // liveness-probe trip (the PR's own named trigger) must also unlatch. const desktop = fakeDesktop() let revalidateCallCount = 0 desktop.revalidateConnection = vi.fn(() => { revalidateCallCount += 1 // Every reconnect attempt after the drop hangs indefinitely; getConnection // itself stays fast so this isolates the revalidate call specifically. return new Promise(() => undefined) }) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') const callsBeforeDrop = desktop.getConnection.mock.calls.length act(() => FakeWebSocket.instances[0].drop()) await advanceBackoff() expect(revalidateCallCount).toBe(1) expect($gatewayState.get()).not.toBe('open') // Still stuck behind the hung revalidate — execution never reached // getConnection() at all. expect(desktop.getConnection.mock.calls.length).toBe(callsBeforeDrop) // Advance past the internal reconnect-attempt timeout (20s) — the stalled // revalidate await must reject (swallowed, as it always was for a genuine // rejection) so execution proceeds to getConnection() and the socket // reopens, instead of latching on the still-pending revalidate forever. await act(async () => { await vi.advanceTimersByTimeAsync(20_000) }) expect(desktop.getConnection.mock.calls.length).toBeGreaterThan(callsBeforeDrop) expect($gatewayState.get()).toBe('open') }) it('onActiveConnectionInvalidated: a fallback getConnection() that hangs rejects on its own instead of latching $connection forever (#93454 sibling)', async () => { // Repro: the active connection is a registered secondary (e.g. a Bots-pane // source). It gets removed/invalidated (disposeSecondariesForConnection), // which falls back to redialing the primary profile via // desktop.getConnection(fallbackProfile) — the one getConnection() await // in this file the #93454 bound-every-IPC-round-trip sweep never reached. // A wedged main-process round-trip here must reject instead of leaving // $connection pointed at a promise that never settles. const desktop = fakeDesktop() as ReturnType & { getConnectionFor: ReturnType } desktop.getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ ...coderConn, connectionId, profile })) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') expect($connection.get()).not.toBeNull() let opening!: Promise act(() => { opening = ensureGatewayForAgent('cloud', 'default') }) await flushAsync() await opening expect(isActivePrimary()).toBe(false) // The active secondary is about to be evicted; the fallback re-dial for // the primary profile hangs indefinitely. desktop.getConnection.mockImplementation(() => new Promise(() => undefined)) act(() => { disposeSecondariesForConnection('cloud') }) await flushAsync() expect(isActivePrimary()).toBe(true) expect(desktop.getConnection).toHaveBeenCalledWith('default') // Still stuck behind the hung fallback dial — the invalidation handler's // .then()/.catch() has not run yet. expect($connection.get()).not.toBeNull() // Advance past the internal reconnect-attempt timeout (20s) — the stalled // fallback getConnection() must reject so the handler's catch publishes // null, instead of latching $connection on a connection that will never // resolve. await act(async () => { await vi.advanceTimersByTimeAsync(20_000) }) expect($connection.get()).toBeNull() }) it('a getConnection() that hangs on INITIAL boot rejects on its own after the reconnect-attempt timeout, not only when main eventually gives up (#93454)', async () => { // boot()'s getConnection() had no bound of its own — only main's own // eventual timeout (e.g. waitForHermes, ~45s) ever settled it. A wedge // that main never resolves (not even a rejection) must not hang // "Starting Hermes…" forever; the renderer needs to own its own bound // here too, same as attemptReconnect() and softSwitch(). const desktop = fakeDesktop() desktop.getConnection = vi.fn(() => new Promise(() => undefined)) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($desktopBoot.get().error).toBeNull() // Advance past the shared backend-boot budget (45s) — the // stalled await must reject on its own so boot()'s catch runs instead of // waiting indefinitely on main. await act(async () => { await vi.advanceTimersByTimeAsync(45_000) }) expect($desktopBoot.get().error).toBeTruthy() }) it('softSwitch(): a getConnection() that hangs on a connection-apply switch does not latch $gatewaySwitching forever (#93454)', async () => { // Repro: main applies a new connection (onConnectionApplied), softSwitch() // re-dials via getConnection(), and the IPC round-trip wedges. Without an // internal timeout, the try block never settles, so the `finally` that // clears $gatewaySwitching never runs — the switch UI stays frozen until // the app is restarted. const desktop = fakeDesktop() const originalGetConnection = desktop.getConnection let callCount = 0 desktop.getConnection = vi.fn((profile?: null | string) => { callCount += 1 // Initial boot succeeds; the switch triggered below hangs indefinitely. return callCount === 1 ? originalGetConnection(profile) : new Promise(() => undefined) }) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') expect(connectionApplied).not.toBeNull() act(() => connectionApplied?.()) await flushAsync() expect($gatewaySwitching.get()).toBe(true) // Advance past the shared backend-boot budget (45s) — the // stalled await must reject so the `finally` clears $gatewaySwitching // instead of latching the switch UI frozen forever. await act(async () => { await vi.advanceTimersByTimeAsync(45_000) }) expect($gatewaySwitching.get()).toBe(false) }) it('rebinds Bot tabs owned by the restarted primary without touching another gateway', async () => { render() await flushAsync() $sessionTiles.set([ { ownerRoute: { connectionId: 'primary-vps', mode: 'remote', profile: 'writer', targetProfile: 'writer' }, runtimeId: 'runtime-primary-dead', storedSessionId: 'primary-bot-chat', workspaceMode: 'bots', workspaceOwnerKey: 'primary-vps::writer' }, { ownerRoute: { connectionId: 'coder-remote', mode: 'remote', profile: 'coder', targetProfile: 'coder' }, runtimeId: 'runtime-secondary-live', storedSessionId: 'secondary-bot-chat', workspaceMode: 'bots', workspaceOwnerKey: 'coder-remote::coder' } ]) act(() => FakeWebSocket.instances[0].drop()) FakeWebSocket.mode = 'open' await advanceBackoff() const [primaryBot, secondaryBot] = $sessionTiles.get() expect(primaryBot).not.toHaveProperty('runtimeId') expect(secondaryBot).toMatchObject({ runtimeId: 'runtime-secondary-live' }) }) it('FIX: a successful reconnect retires the focused composer busy latch (#93059)', async () => { // Backend respawned mid-turn (auto-update, sleep/wake): the focused // composer's draft latches never get their terminal busy:false, and Send // silently no-ops behind the busy guard until restart (#93059). render() await flushAsync() expect($gatewayState.get()).toBe('open') // A turn was mid-flight when the backend went away. act(() => { $busy.set(true) $awaitingResponse.set(true) }) act(() => FakeWebSocket.instances[0].drop()) await flushAsync() // The respawned backend answers the next dial. await advanceBackoff() expect($gatewayState.get()).toBe('open') expect($busy.get()).toBe(false) expect($awaitingResponse.get()).toBe(false) }) it('manual reconnect revalidates, re-resolves, re-mints, and re-dials the dropped socket', async () => { const desktop = fakeDesktop() ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') act(() => FakeWebSocket.instances[0].drop()) FakeWebSocket.mode = 'open' await act(async () => { const reconnect = reconnectGateway() await vi.advanceTimersByTimeAsync(0) await reconnect }) expect(desktop.revalidateConnection).toHaveBeenCalledOnce() // The manual reconnect dials the WINDOW-owned primary backend (no profile // arg) — same contract as the sleep/wake reconnect: passing the active // profile would retarget the primary socket after a live profile swap. const lastCall = desktop.getConnection.mock.calls.at(-1) ?? [] expect(lastCall.length === 0 || lastCall[0] == null || lastCall[0] === '').toBe(true) expect(desktop.getGatewayWsUrl).toHaveBeenCalledTimes(2) expect(FakeWebSocket.instances).toHaveLength(2) expect($gatewayState.get()).toBe('open') }) it('power resume force-redials a half-open primary socket that still reports OPEN', async () => { const desktop = fakeDesktop() ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() const staleSocket = FakeWebSocket.instances[0] expect(staleSocket.readyState).toBe(FakeWebSocket.OPEN) expect($gatewayState.get()).toBe('open') expect(powerResume).not.toBeNull() // macOS can discard the TCP connection during sleep without updating the // renderer WebSocket object. Leave readyState OPEN, swallow the liveness // ping (a half-open socket never answers), and emit only resume. The wake // path no longer blind-closes an open-looking socket — it probes first and // closes only when the probe times out. FakeWebSocket.pingMode = 'silent' act(() => powerResume?.()) await act(async () => { await vi.advanceTimersByTimeAsync(5_100) }) expect(staleSocket.readyState).toBe(FakeWebSocket.CLOSED) // The probe-driven close schedules the regular backoff reconnect, which // revalidates the (possibly dead) remote descriptor before re-dialing. await advanceBackoff() expect(desktop.revalidateConnection).toHaveBeenCalledOnce() expect(FakeWebSocket.instances).toHaveLength(2) expect($gatewayState.get()).toBe('open') }) it('FIX: post-boot ticket-mint boot-progress errors do not lock the UI', async () => { const desktop = fakeDesktop() as ReturnType & { emitBootProgress: (payload: Record) => void } ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') expect($desktopBoot.get().error).toBeNull() // Main re-emits the exact transient ticket error after a liveness rebuild. // That used to promote into BootFailureOverlay and lock reading/drafting. act(() => { desktop.emitBootProgress({ error: 'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.', message: 'Desktop boot failed', phase: 'backend.error', progress: 94, running: false, timestamp: Date.now() }) }) expect($desktopBoot.get().error).toBeNull() expect($desktopBoot.get().visible).toBe(false) }) it('FIX: a failed session-list fetch during boot is non-fatal — the app still boots', async () => { // The version-skew report: gateway WS connects fine, but refreshSessions() // rejects (e.g. older backend 404s an endpoint the fallback didn't cover, // or a transient read error). That must NOT reject boot() into // failDesktopBoot's "Hermes couldn't start" overlay — the socket is open // and the app is fully usable with an empty sidebar. const refreshSessions = vi.fn(async () => { throw new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}') }) render() await flushAsync() expect(refreshSessions).toHaveBeenCalled() expect($gatewayState.get()).toBe('open') // Boot completed: no error, overlay dismissed. expect($desktopBoot.get().error).toBeNull() expect($desktopBoot.get().visible).toBe(false) expect($desktopBoot.get().phase).toBe('renderer.ready') }) it('seeds the configured default project dir pre-connect — no route-resume race (#71873)', async () => { // The reporter's scenario: a configured default project dir must be applied // at boot regardless of route-resume timing. The seed now runs BEFORE the // gateway opens, so no session restore can race it (route-resume is gated // on gatewayState === 'open'). const desktop = fakeDesktop() as { sanitizeWorkspaceCwd?: unknown settings?: unknown } desktop.settings = { getDefaultProjectDir: vi.fn(async () => ({ defaultLabel: 'C:\\Users\\sonny', dir: 'C:\\Hermes', resolvedCwd: 'C:\\Hermes' })), pickDefaultProjectDir: vi.fn(async () => undefined), setDefaultProjectDir: vi.fn(async () => undefined) } desktop.sanitizeWorkspaceCwd = vi.fn(async (cwd: string) => ({ cwd })) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop // Record the cwd at the exact moment the gateway opens its WebSocket: if // the seed moved back post-connect, this would still be '' here and the // end-state assertion would pass anyway (the seed would run later in the // same flush). The construction-time snapshot is what proves ordering. let cwdAtConnect = '' class RecordingSocket extends FakeWebSocket { constructor(url: string) { super(url) cwdAtConnect = $currentCwd.get() } } ;(globalThis as { WebSocket: unknown }).WebSocket = RecordingSocket render() await flushAsync() expect(cwdAtConnect).toBe('C:\\Hermes') expect($currentCwd.get()).toBe('C:\\Hermes') }) it('FIX: primary sleep/wake reconnect dials the window backend, not the active secondary profile', async () => { const desktop = fakeDesktop() ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($gatewayState.get()).toBe('open') expect(FakeWebSocket.instances).toHaveLength(1) expect(FakeWebSocket.instances[0].url).toBe(primaryConn.wsUrl) // Profile swap opens a secondary WS; briefly use real timers so that // handshake isn't wedged behind the suite's fake clock. vi.useRealTimers() await ensureGatewayProfile('coder') vi.useFakeTimers() expect(isActivePrimary()).toBe(false) expect($activeGatewayProfile.get()).toBe('coder') expect($connection.get()?.profile).toBe('coder') expect($connection.get()?.baseUrl).toBe(coderConn.baseUrl) const callsBeforeDrop = desktop.getConnection.mock.calls.length const socketsBeforeDrop = FakeWebSocket.instances.length const primarySocket = FakeWebSocket.instances[0] act(() => primarySocket.drop()) await flushAsync() await advanceBackoff() const reconnectCalls = desktop.getConnection.mock.calls.slice(callsBeforeDrop) expect(reconnectCalls.some(args => (args[0] ?? '').trim() === 'coder')).toBe(false) expect(reconnectCalls.some(args => args.length === 0 || args[0] == null || args[0] === '')).toBe(true) const primaryReconnectSockets = FakeWebSocket.instances .slice(socketsBeforeDrop) .filter(socket => socket.url === primaryConn.wsUrl) expect(primaryReconnectSockets.length).toBeGreaterThan(0) expect($connection.get()?.profile).toBe('coder') expect($connection.get()?.baseUrl).toBe(coderConn.baseUrl) }) it('FIX #82679: a transient remote boot failure self-heals — the next attempt rebuilds the dropped connection', async () => { // The reported class: the app relaunches (or wakes) against a registered // SSH/HTTP remote whose transport dropped. startHermes() rejects with a // transient transport error ("Could not verify the existing SSH backend"), // main tags the boot progress `retryable`, and — before the fix — the app // parked on "Desktop boot failed" until the user re-entered the exact same // connection details. Now the renderer retries the boot with backoff and // the second attempt (fresh bootstrap, same details) succeeds. const desktop = fakeDesktop() desktop.getConnection = vi .fn() .mockRejectedValueOnce(new Error('Could not verify the existing SSH backend.')) .mockImplementation(async () => primaryConn) 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 render() await flushAsync() // First attempt failed but the failure is retryable: no terminal error, // the overlay shows the retry status instead of the dead-end failure. expect($desktopBoot.get().error).toBeNull() expect($gatewayState.get()).not.toBe('open') // Walk past the first backoff delay (2s base, 15s cap, full jitter). await advanceBackoff() // Second boot attempt rebuilt the connection — no manual re-entry. expect(desktop.getConnection.mock.calls.length).toBeGreaterThan(1) expect($gatewayState.get()).toBe('open') expect($desktopBoot.get().error).toBeNull() }) it('FIX #82679: boot retries are BOUNDED — a persistently dead remote ends in the recovery overlay, not a spinner', async () => { const desktop = fakeDesktop() desktop.getConnection = vi.fn(async () => { throw new Error('Could not verify the existing SSH backend.') }) 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 render() await flushAsync() // Exhaust the bounded retry budget (5 attempts, ≤15s jittered delay each). for (let i = 0; i < 7; i += 1) { await advanceBackoff() } // 1 initial + 5 bounded retries; the loop then STOPS retrying and the // terminal boot error surfaces the real recovery affordance. expect(desktop.getConnection).toHaveBeenCalledTimes(6) expect($desktopBoot.get().error).toBeTruthy() // No further attempts after the budget is spent — bounded, not infinite. await advanceBackoff() expect(desktop.getConnection).toHaveBeenCalledTimes(6) }) it('FIX #82679: a NON-retryable boot failure (local / confirmed reauth) fails immediately without auto-retry', async () => { const desktop = fakeDesktop() desktop.getConnection = vi.fn(async () => { throw new Error('401: gateway session expired') }) desktop.getBootProgress = vi.fn(async () => ({ error: '401: gateway session expired', fakeMode: false, message: 'Desktop boot failed: 401: gateway session expired', phase: 'backend.error', progress: 24, retryable: false, running: false, timestamp: Date.now() })) ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop render() await flushAsync() expect($desktopBoot.get().error).toBeTruthy() expect(desktop.getConnection).toHaveBeenCalledTimes(1) // Still no retry later: a missing capability is not a transient failure. await advanceBackoff() expect(desktop.getConnection).toHaveBeenCalledTimes(1) }) it('wake probe: an open-looking but unresponsive socket is force-closed and reconnected', async () => { render() await flushAsync() expect($gatewayState.get()).toBe('open') const socketCountBefore = FakeWebSocket.instances.length // Half-open socket: connectionState reads 'open' (no close event) but the // backend never answers — the sleep/wake TCP black hole. FakeWebSocket.pingMode = 'silent' // A wake signal (power resume / network online / window visible) nudges // reconnectNow. With the socket still reporting open it must PROBE rather // than skip; the swallowed ping times out and forces the socket down. act(() => window.dispatchEvent(new Event('online'))) await act(async () => { await vi.advanceTimersByTimeAsync(5_100) }) // The probe timeout (5s) force-closed the socket → 'closed' → the backoff // timer schedules a reconnect; let it fire and re-dial. await advanceBackoff() // A fresh socket was dialed. expect(FakeWebSocket.instances.length).toBeGreaterThan(socketCountBefore) expect($gatewayState.get()).toBe('open') }) it('wake probe: a healthy socket answers the ping and stays untouched', async () => { render() await flushAsync() expect($gatewayState.get()).toBe('open') const socketCountBefore = FakeWebSocket.instances.length // Default FakeWebSocket behavior: answer pings with a pong frame. act(() => window.dispatchEvent(new Event('online'))) await act(async () => { await vi.advanceTimersByTimeAsync(5_100) }) // Probe succeeded → no forced close, no reconnect, connection untouched. expect(FakeWebSocket.instances.length).toBe(socketCountBefore) expect($gatewayState.get()).toBe('open') }) it('wake probe: a pre-ping backend (-32601) is healthy, not reconnected', async () => { render() await flushAsync() expect($gatewayState.get()).toBe('open') const socketCountBefore = FakeWebSocket.instances.length // Version skew: this gateway predates the ping method. The error response // proves the socket is alive; forcing a reconnect would loop forever. FakeWebSocket.pingMode = 'method-not-found' act(() => window.dispatchEvent(new Event('online'))) await act(async () => { await vi.advanceTimersByTimeAsync(5_100) }) expect(FakeWebSocket.instances.length).toBe(socketCountBefore) expect($gatewayState.get()).toBe('open') }) // #95327: every focus/visibility/power-resume nudge probes an OPEN socket // and force-closes it when the liveness ping times out. A backend that is // merely BUSY (a long silent tool call holding the loop) fails that probe // without being dead — closing the socket mid-turn is exactly what feeds the // gateway's ws_orphan_reap interrupt ("Operation interrupted." placeholder). // While any session still reports working, one inconclusive timeout must // defer the teardown (bounded re-probe) instead of killing the transport. it('wake probe: a timeout while a turn is IN FLIGHT defers the force-close', async () => { render() await flushAsync() expect($gatewayState.get()).toBe('open') const socketCountBefore = FakeWebSocket.instances.length // A turn is running on this very socket; backend silence is expected until // the tool call returns. act(() => { publishSessionState('rt-live-turn', { ...createClientSessionState(null), storedSessionId: 's-live-turn', busy: true }) }) expect($workingSessionIds.get()).toContain('s-live-turn') // Busy-but-alive: the ping is swallowed (loop starved), not refused. FakeWebSocket.pingMode = 'silent' act(() => window.dispatchEvent(new Event('online'))) await act(async () => { await vi.advanceTimersByTimeAsync(5_100) }) // First inconclusive probe with work in flight: the socket must survive — // not merely "some socket is open again after a teardown + redial", but // THIS incarnation, whose transcript stream the running turn rides on. expect($gatewayState.get()).toBe('open') const survivingSocket = FakeWebSocket.instances[socketCountBefore - 1] expect(survivingSocket.readyState).toBe(FakeWebSocket.OPEN) clearAllSessionStates() // Recovery must not wedge once the working flag is gone: persistent // silence still exhausts the streak and rebuilds the transport. await act(async () => { await vi.advanceTimersByTimeAsync(45_000) }) FakeWebSocket.pingMode = 'pong' await act(async () => { await vi.advanceTimersByTimeAsync(20_000) }) expect($gatewayState.get()).toBe('open') expect(FakeWebSocket.instances.length).toBeGreaterThanOrEqual(socketCountBefore) }) it('wake probe: repeated timeouts while busy still rebuild the socket (no deadlock)', async () => { render() await flushAsync() expect($gatewayState.get()).toBe('open') const socketCountBefore = FakeWebSocket.instances.length act(() => { publishSessionState('rt-live-turn-2', { ...createClientSessionState(null), storedSessionId: 's-live-turn-2', busy: true }) }) // Genuinely dead under the working flag: EVERY probe keeps timing out. FakeWebSocket.pingMode = 'silent' for (let nudge = 0; nudge < 3; nudge += 1) { act(() => window.dispatchEvent(new Event('online'))) await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) } clearAllSessionStates() // The streak guard only DELAYS the teardown; a persistently unresponsive // socket is still rebuilt rather than trusted forever. expect(FakeWebSocket.instances.length).toBeGreaterThan(socketCountBefore) FakeWebSocket.pingMode = 'pong' await act(async () => { await vi.advanceTimersByTimeAsync(20_000) }) expect($gatewayState.get()).toBe('open') }) })