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 741d9627ab..be1ef9418a 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 @@ -24,6 +24,7 @@ import { primaryRuntimeConnectionId, useGatewayBoot } from './use-gateway-boot' 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', () => { @@ -151,7 +152,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), @@ -198,6 +205,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') @@ -486,6 +494,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: post-boot ticket-mint boot-progress errors do not lock the UI', async () => { const desktop = fakeDesktop() as ReturnType & { emitBootProgress: (payload: Record) => void 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 30d76bf929..ae300f36b0 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -334,7 +334,7 @@ export function useGatewayBoot({ }, delay) } - const reconnectNow = async () => { + const reconnectNow = async ({ forceOpenSocket = false }: { forceOpenSocket?: boolean } = {}) => { if (cancelled || !bootCompleted || $gatewaySwitching.get()) { return } @@ -343,7 +343,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() @@ -583,9 +593,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 — @@ -599,7 +610,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 eb51610d0c..7db2fb9fae 100644 --- a/apps/desktop/src/store/gateway-connection-lifecycle.test.ts +++ b/apps/desktop/src/store/gateway-connection-lifecycle.test.ts @@ -246,6 +246,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 0ca9f4ae6d..25b11b7a6e 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -886,13 +886,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)