diff --git a/apps/client/src/common/utils/__tests__/socket.test.ts b/apps/client/src/common/utils/__tests__/socket.test.ts index d27c1a3d7..cdf3fffb1 100644 --- a/apps/client/src/common/utils/__tests__/socket.test.ts +++ b/apps/client/src/common/utils/__tests__/socket.test.ts @@ -59,6 +59,7 @@ vi.mock('../../stores/logger', () => ({ addLog: vi.fn() })); describe('socket connection watchdog', () => { let connectSocket: () => void; + let getReconnectAttempts: () => number; beforeEach(async () => { vi.useFakeTimers(); @@ -67,7 +68,9 @@ describe('socket connection watchdog', () => { vi.stubGlobal('WebSocket', MockWebSocket); // the module keeps connection state in module scope, we need a clean one for each test vi.resetModules(); - connectSocket = (await import('../socket')).connectSocket; + const socketModule = await import('../socket'); + connectSocket = socketModule.connectSocket; + getReconnectAttempts = socketModule.getReconnectAttempts; }); afterEach(() => { @@ -108,10 +111,12 @@ describe('socket connection watchdog', () => { vi.advanceTimersByTime(watchdogInterval); expect(socket.readyState).toBe(MockWebSocket.CLOSED); + + vi.advanceTimersByTime(socketConfig.reconnectBaseInterval * 2); expect(MockWebSocket.instances).toHaveLength(2); }); - it('gives up on a connection attempt which never completes', () => { + it('backs off after a connection attempt which never completes', () => { connectSocket(); const socket = MockWebSocket.instances[0]; @@ -119,7 +124,12 @@ describe('socket connection watchdog', () => { vi.advanceTimersByTime(connectTimeout + watchdogInterval); expect(socket.readyState).toBe(MockWebSocket.CLOSED); + expect(MockWebSocket.instances).toHaveLength(1); + expect(getReconnectAttempts()).toBe(0); + + vi.advanceTimersByTime(socketConfig.reconnectBaseInterval * 2); expect(MockWebSocket.instances).toHaveLength(2); + expect(getReconnectAttempts()).toBe(1); }); it('logs one warning while repeated connection attempts time out', () => { @@ -164,6 +174,7 @@ describe('socket connection watchdog', () => { const stale = openConnection(); vi.advanceTimersByTime(silenceTimeout + watchdogInterval); + vi.advanceTimersByTime(socketConfig.reconnectBaseInterval * 2); expect(MockWebSocket.instances).toHaveLength(2); MockWebSocket.instances[1].open(); diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index 6b809957c..3e12b375d 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -276,14 +276,11 @@ function scheduleReconnect() { }, delay); } -/** - * Drops the current connection and immediately opens a new one. - * Used when we have reason to believe the socket is no longer delivering data. - */ -function reconnectNow(reason: string) { +/** Drops a socket that is no longer delivering and retries with the normal backoff. */ +function reconnectWithBackoff(reason: string) { logConnectionIssue(reason); detachSocket(); - connectSocket(); + scheduleReconnect(); } /** Detaches a socket before closing it so late events cannot affect its replacement. */ @@ -325,23 +322,19 @@ function checkConnection() { if (websocket?.readyState === WebSocket.CONNECTING) { // A connection attempt can otherwise hang indefinitely. if (silentFor > socketConfig.connectTimeout) { - reconnectNow('WebSocket: connection attempt timed out'); + reconnectWithBackoff('WebSocket: connection attempt timed out'); } return; } if (websocket?.readyState === WebSocket.OPEN) { if (silentFor > socketConfig.silenceTimeout) { - reconnectNow('WebSocket: no data from server, reconnecting'); + reconnectWithBackoff('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. - */ + // Covers a close event that the browser never reported. if (!reconnectTimeout) { scheduleReconnect(); }