diff --git a/apps/desktop/src/lib/json-rpc-gateway-heartbeat.test.ts b/apps/desktop/src/lib/json-rpc-gateway-heartbeat.test.ts index 549334b027..949d99eb00 100644 --- a/apps/desktop/src/lib/json-rpc-gateway-heartbeat.test.ts +++ b/apps/desktop/src/lib/json-rpc-gateway-heartbeat.test.ts @@ -131,4 +131,45 @@ describe('JsonRpcGatewayClient heartbeat recovery', () => { expect(methods).toEqual(['client.capabilities']) }) + + // A hidden window's intensive wake-up throttling fires the 15 s heartbeat + // once a minute; the backend answers each ping at once, so the newest pong + // is ~60 s old at every tick. Only an UNANSWERED ping is peer silence. + it('keeps an answered socket open across throttled 60 s ticks and drops it once a ping goes unanswered', async () => { + vi.useFakeTimers() + vi.stubGlobal('WebSocket', { OPEN: FakeSocket.OPEN }) + const socket = new FakeSocket() + + const client = new JsonRpcGatewayClient({ + heartbeatDeadlineMs: 45, + heartbeatIntervalMs: 15, + socketFactory: () => socket as unknown as WebSocket + }) + + const connected = client.connect('ws://gateway.test/api/ws') + socket.emit('open') + await connected + socket.message({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: { heartbeat: true } } }) + + const pings = () => + socket.sent.map(text => JSON.parse(text) as { id: string; method: string }).filter(f => f.method === 'gateway.ping') + + // One throttled tick: the clock moves 60 with only the last interval run. + const throttledTick = async () => { + vi.setSystemTime(Date.now() + 45) + await vi.advanceTimersByTimeAsync(15) + } + + for (let i = 0; i < 5; i++) { + await throttledTick() + socket.message({ id: pings().at(-1)!.id, jsonrpc: '2.0', result: { ok: true } }) + } + + expect(pings()).toHaveLength(5) + expect(client.connectionState).toBe('open') + + await throttledTick() + await throttledTick() + expect(client.connectionState).toBe('closed') + }) }) diff --git a/apps/shared/src/json-rpc-channel.ts b/apps/shared/src/json-rpc-channel.ts index ecc3a7627a..3dc139784a 100644 --- a/apps/shared/src/json-rpc-channel.ts +++ b/apps/shared/src/json-rpc-channel.ts @@ -533,12 +533,20 @@ export class JsonRpcRequestChannel { return } + // Silence is judged against the last ping we actually sent. A hidden + // Chromium window spaces these ticks 60 s apart (intensive wake-up + // throttling), so the previous ping's pong is ~60 s old at every tick; + // a deadline on its age alone dropped every idle hidden socket. + let lastPingAt = 0 + this.heartbeatTimer = setInterval(() => { if (this.transport !== transport) { return } - if (Date.now() - this.lastLivenessAt >= this.options.heartbeatDeadlineMs) { + const now = Date.now() + + if (lastPingAt > this.lastLivenessAt && now - this.lastLivenessAt >= this.options.heartbeatDeadlineMs) { this.failHeartbeat(new Error('WebSocket heartbeat acknowledgement timed out')) return @@ -554,6 +562,8 @@ export class JsonRpcRequestChannel { this.outstandingPings.delete(this.outstandingPings.values().next().value as string) } + lastPingAt = now + try { transport.send(JSON.stringify({ jsonrpc: '2.0', id, method: 'gateway.ping', params: {} })) } catch (error) {