Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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(<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 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,
Expand Down
21 changes: 16 additions & 5 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ export function useGatewayBoot({
}, delay)
}

const reconnectNow = async () => {
const reconnectNow = async ({ forceOpenSocket = false }: { forceOpenSocket?: boolean } = {}) => {
if (cancelled || !bootCompleted || $gatewaySwitching.get()) {
return
}
Expand All @@ -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()
Expand Down Expand Up @@ -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 —
Expand All @@ -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') {
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/store/gateway-connection-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/store/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,13 +836,22 @@ export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
// 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)
Expand Down
Loading