Stabilize browser WebSocket heartbeat

Refresh browser-side WebSocket activity tracking on valid inbound messages so normal server heartbeat traffic prevents unnecessary reconnect churn.

Cover the quiet-period timeout path with a regression test.
This commit is contained in:
rcourtman
2026-05-14 09:45:47 +01:00
parent bb77f42868
commit 3baa801f51
3 changed files with 24 additions and 0 deletions
@@ -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
@@ -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 {
+1
View File
@@ -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;