diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 012fefa04..bb3a58952 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -289,6 +289,10 @@ AI-only summary payloads, or page-local heuristics. from websocket `state.resources` instead of layering confirmatory route-local REST refetch loops over already-owned resource updates. + Browser WebSocket liveness tracking is part of that same store boundary: + valid inbound server messages, including heartbeat `ping`/`pong` traffic, + must refresh the browser-side activity timestamp so quiet periods between + resource snapshots do not cause avoidable reconnect churn. That shared store/adapter/hook path must also preserve canonical row shape across transport boundaries: thinner realtime `state.resources` payloads must merge into the existing canonical resource snapshot instead of diff --git a/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts b/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts index 098899652..e4d475c71 100644 --- a/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts +++ b/frontend-modern/src/stores/__tests__/websocket-resilience.test.ts @@ -143,6 +143,25 @@ describe('websocket store resilience', () => { } }); + it('keeps the websocket open when server activity arrives before heartbeat timeout', async () => { + const { dispose } = await createStoreHarness(); + try { + vi.advanceTimersByTime(1); // run onopen tick + expect(currentInstance).not.toBeNull(); + + vi.advanceTimersByTime(89_000); + currentInstance!.onmessage?.({ + data: JSON.stringify({ type: 'pong', data: { timestamp: Date.now() } }), + } as MessageEvent); + vi.advanceTimersByTime(2_000); + + expect(currentInstance!.close).not.toHaveBeenCalledWith(4000, 'Heartbeat timeout'); + expect(currentInstance!.close).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); + it('manual reconnect avoids duplicate reconnect scheduling', async () => { const { store, dispose } = await createStoreHarness(); try { diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index bbea9b9a2..12870e68e 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -411,6 +411,7 @@ export function createWebSocketStore(url: string) { logger.error('Failed to parse WebSocket message', parseError); return; } + lastServerActivityAt = Date.now(); try { const message = data as TimestampedWSMessage;