Files
aiturk-hermes-ide/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
T

1787 lines
64 KiB
TypeScript

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<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).
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<string, unknown>) => 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<string, unknown>) {
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<void>
refreshSessions?: (shouldPublish?: () => boolean) => Promise<void>
} = {}) {
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(<Harness />)
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(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />)
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(<Harness />)
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(<Harness />)
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<typeof beginGatewaySwitch> = 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(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />)
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<typeof beginGatewaySwitch> = null
beforeConnectionSwitch.mockImplementationOnce(() => {
newerToken = beginGatewaySwitch()
throw failure
})
render(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />)
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(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />)
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<typeof fakeDesktop> & Record<string, unknown>
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(<Harness beforeConnectionSwitch={beforeConnectionSwitch} />)
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<typeof fakeDesktop> & Record<string, unknown>
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(<Harness />)
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<typeof vi.fn>).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<void>()
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(<Harness refreshSessions={refreshSessions} />)
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(<Harness refreshSessions={refreshSessions} />)
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<typeof fakeDesktop> & Record<string, unknown>
const staleSanitize = deferred<{ cwd: string }>()
const staleConfig = deferred<void>()
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(<Harness refreshHermesConfig={refreshHermesConfig} />)
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(<Harness />)
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<typeof fakeDesktop> & {
getConnectionFor: ReturnType<typeof vi.fn>
}
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(<Harness />)
await flushAsync()
expect($gatewayState.get()).toBe('open')
let opening!: Promise<boolean>
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<typeof fakeDesktop> & {
api: ReturnType<typeof vi.fn>
}
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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<typeof fakeDesktop> & {
getConnectionFor: ReturnType<typeof vi.fn>
}
desktop.getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
...coderConn,
connectionId,
profile
}))
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
render(<Harness />)
await flushAsync()
expect($gatewayState.get()).toBe('open')
expect($connection.get()).not.toBeNull()
let opening!: Promise<boolean>
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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<typeof fakeDesktop> & {
emitBootProgress: (payload: Record<string, unknown>) => void
}
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
render(<Harness />)
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(<Harness refreshSessions={refreshSessions} />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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(<Harness />)
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')
})
})