Force periodic SSE reconnection to bound silent-connection blind spots

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-07 13:29:23 +00:00
1 parent 4a0f7960ff
commit e8bfbe4168
1 file changed
+29 -2
+29 -2
View File
@@ -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,7 +22,13 @@ export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => vo
useEffect(() => {
if (!url) return;
const source = new EventSource(url);
let source: EventSource;
let reconnectTimer: ReturnType<typeof setTimeout>;
let cancelled = false;
function connect() {
source = new EventSource(url);
source.onmessage = (e) => {
try {
handlerRef.current(JSON.parse(e.data) as TicketEvent);
@@ -20,6 +36,17 @@ export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => vo
// malformed payload — ignore rather than crash the subscription
}
};
return () => source.close();
reconnectTimer = setTimeout(() => {
source.close();
if (!cancelled) connect();
}, FORCE_RECONNECT_MS);
}
connect();
return () => {
cancelled = true;
clearTimeout(reconnectTimer);
source.close();
};
}, [url]);
}