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:
Teknium
2026-10-04 19:13:50 -07:00
parent 8aea05810b
commit 1dfef400a7
2 changed files with 52 additions and 1 deletions

View File

@@ -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')
})
})

View File

@@ -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) {