fix: apply backoff to watchdog reconnects

This commit is contained in:
Carlos Valente
2026-09-15 18:16:51 +02:00
parent 1d402385ac
commit bb6588a50a
2 changed files with 19 additions and 15 deletions
@@ -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();
+6 -13
View File
@@ -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();
}