diff --git a/apps/client/src/common/utils/__tests__/socket.test.ts b/apps/client/src/common/utils/__tests__/socket.test.ts index 21a96191c..d27c1a3d7 100644 --- a/apps/client/src/common/utils/__tests__/socket.test.ts +++ b/apps/client/src/common/utils/__tests__/socket.test.ts @@ -40,6 +40,10 @@ class MockWebSocket { this.onclose?.(); } + /** Simulates a lost connection without an onclose event. */ + closeSilently() { + this.readyState = MockWebSocket.CLOSED; + } open() { this.readyState = MockWebSocket.OPEN; this.onopen?.(); @@ -146,6 +150,16 @@ describe('socket connection watchdog', () => { expect(MockWebSocket.instances).toHaveLength(2); }); + it('recovers when a close goes unreported', () => { + const socket = openConnection(); + + // The watchdog is the only recovery path when no close event arrives. + socket.closeSilently(); + + vi.advanceTimersByTime(watchdogInterval + socketConfig.reconnectBaseInterval * 2); + expect(MockWebSocket.instances).toHaveLength(2); + }); + it('ignores events from a socket which has been replaced', () => { const stale = openConnection(); diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index f843227e3..6b809957c 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -320,13 +320,9 @@ function registerConnectionAttempt() { * while paused, so extended silence is evidence of a dropped connection. */ function checkConnection() { - if (!websocket) { - return; - } - const silentFor = Date.now() - lastContact; - if (websocket.readyState === WebSocket.CONNECTING) { + if (websocket?.readyState === WebSocket.CONNECTING) { // A connection attempt can otherwise hang indefinitely. if (silentFor > socketConfig.connectTimeout) { reconnectNow('WebSocket: connection attempt timed out'); @@ -334,8 +330,20 @@ function checkConnection() { return; } - if (websocket.readyState === WebSocket.OPEN && silentFor > socketConfig.silenceTimeout) { - reconnectNow('WebSocket: no data from server, reconnecting'); + if (websocket?.readyState === WebSocket.OPEN) { + if (silentFor > socketConfig.silenceTimeout) { + reconnectNow('WebSocket: no data from server, reconnecting'); + } + return; + } + + /** + * The socket is closing, closed or was never created. + * Closing schedules its own reconnect, this is what covers us if that did not happen, + * so that there is no state the client can settle in without a way out. + */ + if (!reconnectTimeout) { + scheduleReconnect(); } }