fix(desktop): force gateway reconnect after wake
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user