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:
1 parent
4a0f7960ff
commit
e8bfbe4168
1 file changed
+29
-2
@@ -3,6 +3,16 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import type { TicketEvent } from "@/lib/events/bus";
|
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). */
|
/** 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) {
|
export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => void) {
|
||||||
const handlerRef = useRef(onEvent);
|
const handlerRef = useRef(onEvent);
|
||||||
@@ -12,7 +22,13 @@ export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => vo
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) return;
|
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) => {
|
source.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
handlerRef.current(JSON.parse(e.data) as TicketEvent);
|
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
|
// 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]);
|
}, [url]);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user