diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index a208ae57d9717..e48f567f207f9 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -23,6 +23,7 @@ import { useGatewayBoot } from './use-gateway-boot' type Listener = (ev: unknown) => void let connectionApplied: null | (() => void) = null +let powerResume: null | (() => void) = null // Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect() // touches: readyState, add/removeEventListener('open'|'error'|'close'), close(). @@ -122,7 +123,13 @@ function fakeDesktop() { connectionApplied = null } }), - onPowerResume: vi.fn(() => () => undefined), + 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), @@ -168,6 +175,7 @@ beforeEach(() => { FakeWebSocket.mode = 'open' FakeWebSocket.instances = [] connectionApplied = null + powerResume = null ;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop() $gatewayState.set('idle') @@ -423,6 +431,32 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => 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 and emit only resume. + act(() => powerResume?.()) + await flushAsync() + await flushAsync() + + expect(staleSocket.readyState).toBe(FakeWebSocket.CLOSED) + expect(desktop.revalidateConnection).toHaveBeenCalledOnce() + expect(FakeWebSocket.instances).toHaveLength(2) + expect($gatewayState.get()).toBe('open') + }) + 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, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 0300430b30ed8..6649104d2d46c 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -299,7 +299,7 @@ export function useGatewayBoot({ }, delay) } - const reconnectNow = async () => { + const reconnectNow = async ({ forceOpenSocket = false }: { forceOpenSocket?: boolean } = {}) => { if (cancelled || !bootCompleted || $gatewaySwitching.get()) { return } @@ -308,7 +308,17 @@ export function useGatewayBoot({ reconnectAttempt = 0 reconnectFailingSince = null escalated = false - reconnectSecondaryGateways() + reconnectSecondaryGateways({ forceOpenSockets: forceOpenSocket }) + + // Browser WebSocket state can remain OPEN after sleep even though the OS + // discarded the underlying TCP connection. Strong recovery signals must + // retire that half-open socket before the normal reconnect path can run. + if (forceOpenSocket && gatewayOpen()) { + gateway.close() + // close() publishes `closed`, which schedules the regular backoff. + // This path reconnects immediately, so remove that redundant timer. + clearReconnectTimer() + } if (!gatewayOpen()) { await attemptReconnect() @@ -545,9 +555,10 @@ export function useGatewayBoot({ // Wake signals: power resume (macOS/Windows), network coming back, and the // window regaining focus/visibility. Each nudges an immediate reconnect. - const offPowerResume = desktop.onPowerResume?.(() => void reconnectNow()) + const forceReconnectNow = () => reconnectNow({ forceOpenSocket: true }) + const offPowerResume = desktop.onPowerResume?.(() => void forceReconnectNow()) const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch()) - const offGatewayReconnect = registerGatewayReconnect(reconnectNow) + const offGatewayReconnect = registerGatewayReconnect(forceReconnectNow) // Registry lifecycle: a removed connection's secondaries must close NOW // (remote/cloud have no local process whose death would drop the socket — @@ -561,7 +572,7 @@ export function useGatewayBoot({ disposeSecondariesForConnection(payload.connectionId, { redial: payload.reason === 'updated' }) }) - const onOnline = () => void reconnectNow() + const onOnline = () => void forceReconnectNow() const onVisible = () => { if (document.visibilityState === 'visible') { diff --git a/apps/desktop/src/store/gateway-connection-lifecycle.test.ts b/apps/desktop/src/store/gateway-connection-lifecycle.test.ts index fde23951a0a6f..b3359f6a93636 100644 --- a/apps/desktop/src/store/gateway-connection-lifecycle.test.ts +++ b/apps/desktop/src/store/gateway-connection-lifecycle.test.ts @@ -218,6 +218,29 @@ describe('retireLocalProfileGateways', () => { }) }) +describe('reconnectSecondaryGateways', () => { + it('force-redials an open secondary whose transport may be half-open after wake', async () => { + const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => + descriptorFor(connectionId, profile) + ) + + installDesktop({ getConnectionFor }) + + await ensureGatewayForAgent('homelab', 'default') + expect(gatewayMocks.connect).toHaveBeenCalledTimes(1) + expect(gatewayMocks.instances[0].connectionState).toBe('open') + + reconnectSecondaryGateways({ forceOpenSockets: true }) + + await vi.waitFor(() => { + expect(gatewayMocks.connect).toHaveBeenCalledTimes(2) + }) + expect(gatewayMocks.instances[0].close).toHaveBeenCalledOnce() + expect(getConnectionFor).toHaveBeenCalledTimes(2) + expect(gatewayMocks.instances[0].connectionState).toBe('open') + }) +}) + describe('reconnect fail-stop on a removed connection', () => { it('evicts the entry instead of retrying when the registry no longer knows the id', async () => { const getConnectionFor = vi diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 5b723194d9f36..07937d06d42d2 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -836,13 +836,22 @@ export async function ensureActiveGatewayOpen(): Promise { // activation before reporting the gateway as unavailable. const ACTIVE_GATEWAY_OPEN_WAIT_MS = 8_000 -// Wake signal (sleep/network/visibility): nudge every live secondary back open. -export function reconnectSecondaryGateways(): void { +// Recovery signal: nudge every live secondary back open. Power-resume/network +// signals can force sockets that still report open to retire before redialing. +export function reconnectSecondaryGateways({ forceOpenSockets = false }: { forceOpenSockets?: boolean } = {}): void { for (const entry of g.secondaries.values()) { - if (!entry.wantOpen || isOpen(entry.gateway)) { + if (!entry.wantOpen) { continue } + if (isOpen(entry.gateway)) { + if (!forceOpenSockets) { + continue + } + + entry.gateway.close() + } + entry.reconnectAttempt = 0 clearTimer(entry) void reconnectSecondary(entry)