From e8bfbe4168e3f2ed473f05999f07ec102338a7b2 Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 7 Aug 2026 13:29:23 +0000 Subject: [PATCH] Force periodic SSE reconnection to bound silent-connection blind spots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: sound/toast stop firing if the page has sat idle for ~30 minutes. EventSource only auto-reconnects when the connection actually errors — if something on the network path (a NAT/router dropping a long-lived connection without a proper close, which the 25s server-side heartbeat can't reveal to client code, since heartbeat comments aren't exposed via onmessage) kills it silently, the subscription can hang indefinitely with no visible symptom until an event is missed. Now each SSE connection is proactively closed and reopened every 4 minutes regardless of apparent health, capping how long that blind spot can last independent of the exact cause. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- src/lib/events/use-ticket-events.ts | 43 +++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/lib/events/use-ticket-events.ts b/src/lib/events/use-ticket-events.ts index dbbba47..b389b88 100644 --- a/src/lib/events/use-ticket-events.ts +++ b/src/lib/events/use-ticket-events.ts @@ -3,6 +3,16 @@ import { useEffect, useLayoutEffect, useRef } from "react"; import type { TicketEvent } from "@/lib/events/bus"; +// A connection can go silently dead — e.g. a NAT/router along the path +// drops a long-lived connection without ever sending a proper close, so +// no error ever fires. EventSource only auto-reconnects on an actual +// error, so a silent death leaves the subscription hanging indefinitely +// with no visible symptom until an event is missed (reported: sound/toast +// stopping after ~30 minutes of an idle tab). Forcing a fresh connection +// periodically bounds how long that blind spot can last, regardless of +// the exact cause. +const FORCE_RECONNECT_MS = 4 * 60 * 1000; + /** Subscribes to a ticket-events SSE endpoint for the lifetime of the component. Pass "" to defer subscribing (e.g. until a token is available). */ export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => void) { const handlerRef = useRef(onEvent); @@ -12,14 +22,31 @@ export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => vo useEffect(() => { if (!url) return; - const source = new EventSource(url); - source.onmessage = (e) => { - try { - handlerRef.current(JSON.parse(e.data) as TicketEvent); - } catch { - // malformed payload — ignore rather than crash the subscription - } + + let source: EventSource; + let reconnectTimer: ReturnType; + let cancelled = false; + + function connect() { + source = new EventSource(url); + source.onmessage = (e) => { + try { + handlerRef.current(JSON.parse(e.data) as TicketEvent); + } catch { + // malformed payload — ignore rather than crash the subscription + } + }; + reconnectTimer = setTimeout(() => { + source.close(); + if (!cancelled) connect(); + }, FORCE_RECONNECT_MS); + } + + connect(); + return () => { + cancelled = true; + clearTimeout(reconnectTimer); + source.close(); }; - return () => source.close(); }, [url]); }