fix(shared): a hidden Desktop window no longer drops its own healthy gateway socket
The gateway.ping heartbeat failed the transport when the newest inbound frame was older than the 45 s deadline. A hidden or minimized Chromium window runs the 15 s heartbeat interval once a minute (intensive wake-up throttling), so at every tick the previous ping's pong is ~60 s old even though the backend answered it within milliseconds. Every idle hidden window closed a healthy socket, redialed, and the renderer recorded hermes.desktop.friction kind=backend_disconnect detail=network for it. Judge silence only when the newest ping we sent is still unanswered (lastPingAt > lastLivenessAt). A dead transport still fails one tick after its first unanswered ping, throttled or not; an answered one is never dropped for timer spacing. This also covers the suspended-process shape (OS sleep, WebView freezer, SIGSTOP): the pre-suspend ping was answered, so the first tick after resume pings instead of failing.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user