/** * Reconnect pacing for the chat stream. * * Every deploy is a symlink swap and a service restart with no connection * draining, so every open chat drops at once and every client tries to come * back at the same moment. Pacing that is the whole job of this module. * * Ported from synckit-client's `reconnect_delay` (MNW/shared/synckit-client, * src/client/subscribe.rs): 1s, 2s, 4s and so on, capped, with jitter so a fleet * does not reconnect in lockstep, and a consecutive-failure ceiling so a * permanently broken server is not retried silently forever. * * Pure on purpose. No timers, no network, no DOM: the caller supplies the clock * and the randomness, which is what makes the pacing testable without waiting * real seconds for it. */ /** Cap on the backoff delay, in milliseconds. */ export const MAX_RECONNECT_DELAY_MS = 60_000; /** * Consecutive failed attempts before giving up and telling the user. * * A single success resets the count, so this only fires when the server has been * unreachable for a sustained stretch. At the 60s cap that is several minutes, * after which a visible "reconnect" affordance beats retrying into the void. */ export const MAX_RECONNECT_ATTEMPTS = 10; /** * Exponent ceiling. 2^6 = 64s already exceeds the delay cap, so growing the * exponent past this only risks overflow for no behavioral difference. */ const MAX_EXPONENT = 6; /** Jitter spread, as a fraction of the base delay. */ const JITTER_FRACTION = 0.2; /** * Delay before attempt number `attempt` (zero-based). * * `random` returns a value in [0, 1) and is injected so tests can pin the * jitter. Defaults to `Math.random`. */ export function reconnectDelayMs( attempt: number, random: () => number = Math.random, ): number { const exponent = Math.min(Math.max(attempt, 0), MAX_EXPONENT); const base = Math.min(1000 * 2 ** exponent, MAX_RECONNECT_DELAY_MS); const span = base * JITTER_FRACTION; // Uniform across [base - span, base + span]. Jitter is added after the cap, so // clamp the result back down: +20% on a capped base would otherwise return ~72s, // above the documented MAX_RECONNECT_DELAY_MS. const delta = random() * 2 * span - span; const jittered = Math.round(base + delta); return Math.min(MAX_RECONNECT_DELAY_MS, Math.max(0, jittered)); } /** What the caller should do next. */ export type ReconnectAction = | { kind: "retry"; delayMs: number; attempt: number } | { kind: "give-up" }; /** * Tracks consecutive failures and hands back the next move. * * Deliberately not a timer. It says when to reconnect, never performs one, so * the same object drives both the real client and its tests. */ export class ReconnectPolicy { #attempt = 0; readonly #random: () => number; // An explicit field rather than a constructor parameter property: Node's // type stripping erases types but cannot transform syntax, and a parameter // property is a transform. constructor(random: () => number = Math.random) { this.#random = random; } /** Consecutive failures since the last success. */ get attempt(): number { return this.#attempt; } /** Record a failed connection and get the next move. */ fail(): ReconnectAction { if (this.#attempt >= MAX_RECONNECT_ATTEMPTS) { return { kind: "give-up" }; } const delayMs = reconnectDelayMs(this.#attempt, this.#random); const attempt = ++this.#attempt; return { kind: "retry", delayMs, attempt }; } /** * Record a successful connection. * * Resets the streak, so a client that reconnects fine every deploy never * accumulates its way to the give-up ceiling over a long-lived tab. */ succeed(): void { this.#attempt = 0; } /** * Drop the accumulated backoff and allow an immediate attempt. * * For the cases where waiting out the backoff is obviously wrong: the tab * became visible again, or the browser reported the network came back. The * delay exists to avoid hammering a struggling server, and neither of those * events says anything about the server, so honoring a 60s wait would just * make chat look broken for a minute after the laptop opens. */ resetForImmediateRetry(): void { this.#attempt = 0; } }