From 84ea005b65badd60c342e858e0e77cdfd97eb512 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 11 Sep 2026 14:10:29 +0200 Subject: [PATCH] feat(msg-bus): free WS if tab is not active since 1min --- frontend/src/lib/message-bus/client.svelte.ts | 146 +++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/message-bus/client.svelte.ts b/frontend/src/lib/message-bus/client.svelte.ts index d3343487..c35f4f9d 100644 --- a/frontend/src/lib/message-bus/client.svelte.ts +++ b/frontend/src/lib/message-bus/client.svelte.ts @@ -110,6 +110,27 @@ const RECONNECT_MAX_MS = 30_000; * genuine permanent failure before it becomes noise. */ const MAX_CONSECUTIVE_FAILURES = 20; +/** How long a tab must stay hidden before the client proactively + * closes its WebSocket. Balances two costs: + * + * - Aggressive close (0 grace) churns on every alt-tab: users + * switch tabs dozens of times a day for quick lookups; a full + * ticket exchange + reconnect on every switch is wasteful. + * - No close leaves the WS holding an fd, a broadcast receiver + * slot, and the session's outbound `mpsc::Sender` server-side + * for as long as the tab is open — even if the user hasn't + * looked at it in hours. + * + * 60 s comfortably absorbs "alt-tab, check something, come back" + * and starts saving real state on tabs left in the background for + * real work. On return we run the same `onReconnect` handlers the + * server-restart path uses — no new code needed for state resync. + * + * The Page Visibility API (`document.visibilityState`) fires the + * same event whether the user switched tabs, minimised the window, + * or the screen locked. All three want the same treatment. */ +const HIDDEN_GRACE_MS = 60_000; + interface SubEntry { count: number; handlers: Set; @@ -155,6 +176,15 @@ export class MessageBusClient { * reactive. Same rationale as `#subs` / `#pending`. */ // eslint-disable-next-line svelte/prefer-svelte-reactivity #reconnectHandlers = new Set(); + /** setTimeout handle for the "close on hidden after grace" timer. + * `null` when the tab is visible OR the timer already fired. See + * `HIDDEN_GRACE_MS` for the design tradeoff. */ + #hiddenTimer: ReturnType | null = null; + /** Bound `visibilitychange` listener kept so `close()` can + * detach it. Not attached in SSR (`typeof document === + * "undefined"`); the client is lazy so this is just belt-and- + * braces against a caller doing something unusual. */ + #onVisibilityChange: (() => void) | null = null; /** `topic` → `{count, handlers, revokedHandlers, acked}`. Refcount * drives the wire: first refcount ⇒ send `rt.subscribe`; last drop @@ -185,6 +215,16 @@ export class MessageBusClient { }; this.#url = opts?.url ?? (typeof window !== 'undefined' ? defaultUrl() : ''); this.#WebSocketCtor = opts?.WebSocketCtor ?? WebSocket; + + // Wire the Page Visibility hook — closes the WS after + // `HIDDEN_GRACE_MS` when the tab goes hidden, reconnects on + // return. See the constant's doc for the tradeoff. Guarded by + // `typeof document !== 'undefined'` so SSR / non-browser + // harnesses (Vitest with a stubbed WebSocket) don't crash. + if (typeof document !== 'undefined') { + this.#onVisibilityChange = () => this.#handleVisibilityChange(); + document.addEventListener('visibilitychange', this.#onVisibilityChange); + } } /** @@ -293,6 +333,14 @@ export class MessageBusClient { clearTimeout(this.#reconnectTimer); this.#reconnectTimer = null; } + if (this.#hiddenTimer !== null) { + clearTimeout(this.#hiddenTimer); + this.#hiddenTimer = null; + } + if (this.#onVisibilityChange && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.#onVisibilityChange); + this.#onVisibilityChange = null; + } if (this.#ws) { this.#ws.close(); this.#ws = null; @@ -302,6 +350,86 @@ export class MessageBusClient { this.#pending.clear(); } + // ─────────────────────── page visibility ───────────────────────── + + /** `visibilitychange` handler. Two transitions: + * + * - visible → hidden: start the grace timer (or reset it, if + * the timer was already running from a previous hide → visible + * → hide flip that didn't fire yet — clearing first is safe). + * - hidden → visible: cancel the timer if it hasn't fired; if + * the WS was already closed AND we still hold subscriptions, + * trigger a reconnect so the `onReconnect` handlers refetch + * and the state catches up. + * + * Wrapped in `untrack` because this method reads `this.state` + * (a `$state`); the caller is a DOM event listener, but + * defensively we don't want a future refactor that puts this + * behind an `$effect` to inherit a dep on `state`. Same + * pattern applied to every other class-method state read — + * see the `subscribe()` docstring for the general rule. */ + #handleVisibilityChange(): void { + untrack(() => { + if (typeof document === 'undefined') return; + if (document.visibilityState === 'hidden') { + if (this.#hiddenTimer !== null) clearTimeout(this.#hiddenTimer); + this.#hiddenTimer = setTimeout(() => this.#closeForHidden(), HIDDEN_GRACE_MS); + busLog.debug('tab hidden — WS close scheduled', { graceMs: HIDDEN_GRACE_MS }); + } else { + if (this.#hiddenTimer !== null) { + clearTimeout(this.#hiddenTimer); + this.#hiddenTimer = null; + busLog.debug('tab visible again — hidden-close cancelled (WS still open)'); + } + // If the WS was closed by the previous grace-timer fire, + // pop back up. `reconnect()` zeroes the circuit breaker + // and schedules an immediate attempt; `#onOpen` will + // then fire every registered `onReconnect` handler and + // consumers refetch to catch up on missed events. Skip + // if there are no live subscribers — no point opening + // a connection nobody's listening on. + if (this.state === 'disconnected' && this.#subs.size > 0) { + busLog.debug('tab visible again — reconnecting after grace close'); + this.reconnect(); + } + } + }); + } + + /** Grace timer fired — the tab has been hidden for `HIDDEN_GRACE_MS`. + * Close the WS, preserving the local `#subs` map so a return to + * visible can re-subscribe every topic through the normal + * `#onOpen` replay path. Nothing to do if we're already + * disconnected (server-restart flow, etc.). */ + #closeForHidden(): void { + untrack(() => { + this.#hiddenTimer = null; + if (this.state === 'idle' || this.state === 'disconnected') return; + busLog.warn('closing WS — tab hidden past grace window', { + subsPreserved: this.#subs.size + }); + if (this.#ws) { + this.#ws.close(); + this.#ws = null; + } + // Cancel any in-flight reconnect timer — the tab is asleep, + // no point scheduling more attempts until it's visible again. + if (this.#reconnectTimer !== null) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + this.state = 'disconnected'; + // Reject pending calls with a synthetic "hidden" error so + // callers don't hang. Matches the `#onClose` shape. + const closed: MessageBusError = { + code: RtErrorCode.INTERNAL_ERROR, + message: 'ws_closed_tab_hidden' + }; + for (const pending of this.#pending.values()) pending.reject(closed); + this.#pending.clear(); + }); + } + // ─────────────────────── connection lifecycle ──────────────────── #connect(): void { @@ -499,8 +627,22 @@ export class MessageBusClient { const closed: MessageBusError = { code: RtErrorCode.INTERNAL_ERROR, message: 'ws_closed' }; for (const pending of this.#pending.values()) pending.reject(closed); this.#pending.clear(); - // Only reconnect if we still have subscribers waiting. - if (this.#subs.size > 0) this.#scheduleReconnect(); + // Reconnect only when we still have subscribers AND the tab is + // currently visible. When hidden, `#closeForHidden` closes the + // WS on purpose to save resources — auto-reconnecting here + // would defeat the whole grace-close mechanism. The + // `visibilitychange` handler's hidden→visible transition takes + // care of the recovery via `this.reconnect()`. + if (this.#subs.size > 0 && !this.#tabIsHidden()) { + this.#scheduleReconnect(); + } + } + + /** Small helper — `true` if the Page Visibility API says the tab + * is hidden right now. Guards non-browser harnesses (SSR, + * Vitest without jsdom overrides) that lack `document`. */ + #tabIsHidden(): boolean { + return typeof document !== 'undefined' && document.visibilityState === 'hidden'; } #scheduleReconnect(overrideMs?: number): void {