diff --git a/src/state/connection.ts b/src/state/connection.ts index 9239202..658fa56 100644 --- a/src/state/connection.ts +++ b/src/state/connection.ts @@ -28,6 +28,7 @@ import { import { getBaseServerUrl } from "lib/settings"; import * as store from "lib/store"; import { nanoid } from "nanoid"; +import { getReconnectDelayMs } from "./reconnectDelay"; type OutgoingMsg = ClientToHostMsg | ClientToServerMsg; export type SendableMsg = { @@ -165,7 +166,6 @@ const PING_INTERVAL_MS = 25_000; // slack before we tear down and reconnect. const LIVENESS_TIMEOUT_MS = 55_000; const MAX_RECONNECT_ATTEMPTS = 10; -const RECONNECT_DELAYS = [1_000, 2_000, 4_000, 4_000, 8_000, 16_000]; // Treat a ticket as spent slightly before it really lapses: it has to survive // the trip to the relay and be verified there, not merely be valid at the // moment we read it out of the cache. @@ -864,7 +864,7 @@ class ConnectionManager { batteryInfo: null, reconnectAttempt: 0, }); - this.attemptReconnect(hostId); + this.attemptReconnect(hostId, true); } /** @@ -937,7 +937,7 @@ class ConnectionManager { this.setSnapshot({ reconnectAttempt: 0 }); } - private async attemptReconnect(hostId: string) { + private async attemptReconnect(hostId: string, immediate = false) { const key = this.encryptionKey; const url = this.snapshot.serverUrl || (await getBaseServerUrl()); @@ -961,17 +961,7 @@ class ConnectionManager { this.setSnapshot({ reconnectAttempt: this.reconnectAttempt }); - const base = - RECONNECT_DELAYS[ - Math.max( - Math.min(this.reconnectAttempt - 1, RECONNECT_DELAYS.length - 1), - 0, - ) - ]; - // jitter (±20%) avoids many clients hammering a shared server - // a server restart drops every client at once, so a fixed ladder would have - // them all retry on the same instants. - const delay = base * (0.8 + Math.random() * 0.4); + const delay = getReconnectDelayMs(this.reconnectAttempt, immediate); this.reconnectTimeout = setTimeout(async () => { this.reconnectTimeout = null; diff --git a/src/state/reconnectDelay.test.ts b/src/state/reconnectDelay.test.ts new file mode 100644 index 0000000..75c13a2 --- /dev/null +++ b/src/state/reconnectDelay.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import { getReconnectDelayMs } from "./reconnectDelay"; + +describe("getReconnectDelayMs", () => { + it("does not delay an explicit recovery attempt", () => { + const random = vi.fn(() => 0.5); + + expect(getReconnectDelayMs(1, true, random)).toBe(0); + expect(random).not.toHaveBeenCalled(); + }); + + it("preserves jittered backoff for ordinary reconnects", () => { + expect(getReconnectDelayMs(1, false, () => 0)).toBe(800); + expect(getReconnectDelayMs(2, false, () => 0.5)).toBe(2_000); + expect(getReconnectDelayMs(3, false, () => 1)).toBeCloseTo(4_800); + }); + + it("uses the final backoff value after the configured ladder", () => { + expect(getReconnectDelayMs(99, false, () => 0.5)).toBe(16_000); + }); +}); diff --git a/src/state/reconnectDelay.ts b/src/state/reconnectDelay.ts new file mode 100644 index 0000000..c9b7cbb --- /dev/null +++ b/src/state/reconnectDelay.ts @@ -0,0 +1,22 @@ +const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 4_000, 8_000, 16_000]; + +/** + * Return the delay before a reconnect attempt. Explicit recovery actions such + * as app resume and network-online bypass backoff for their first attempt. + */ +export function getReconnectDelayMs( + attempt: number, + immediate = false, + random = Math.random, +): number { + if (immediate) return 0; + + const base = + RECONNECT_DELAYS_MS[ + Math.max(Math.min(attempt - 1, RECONNECT_DELAYS_MS.length - 1), 0) + ]; + + // Jitter (±20%) keeps clients disconnected by the same relay event from + // retrying in lockstep. + return base * (0.8 + random() * 0.4); +}