fix(desktop): force gateway reconnect after wake

This commit is contained in:
yu-xin-c
2026-08-18 18:15:32 +08:00
committed by Teknium
parent 9b3f60c029
commit febed060ab
4 changed files with 86 additions and 9 deletions
@@ -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(<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: 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
@@ -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') {
@@ -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
+12 -3
View File
@@ -886,13 +886,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)