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)