fix(dashboard): close SSE EventSources on page unload to prevent freeze
After ~3 refreshes, the dashboard would hang on "Initializing dashboard..." with all /api/* fetches stalling. Root cause: Chrome keeps HTTP/1.1 sockets in its keep-alive pool across page navigations even after EventSource is garbage-collected. Once 6 (the per-origin limit) are held, every new fetch queues indefinitely and the app can't finish booting. Fix, layered: 1. sse-bus.ts — pagehide/beforeunload listeners close all active channels and send a sendBeacon to /api/events/disconnect so the server forces the socket closed (socket.destroy) rather than waiting for the browser to notice. Uses a sessionStorage clientId to correlate. 2. api.ts — createResilientEventSource (used by planning / mission / slice stream endpoints) registers every handle in a module-level set and closes them all on pagehide/beforeunload. sse-bus doesn't see these streams, so it needs its own teardown. 3. sse.ts — server-side connection bookkeeping. Tracks managed SSE connections by clientId, supports client-triggered disconnect via POST /api/events/disconnect, stale-timer cleanup, and supersedes older streams when a client reconnects. 4. server.ts — exposes /api/events/disconnect and /api/events/keepalive under a dedicated 300 req/min rate limit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,85 @@ import type { AiSessionStore } from "./ai-session-store.js";
|
||||
|
||||
let activeConnections = 0;
|
||||
let highWaterMark = 0;
|
||||
let nextConnectionId = 1;
|
||||
|
||||
const SSE_CLIENT_ID_MAX_LENGTH = 128;
|
||||
const SSE_CLIENT_STALE_MS = 5_000;
|
||||
|
||||
type SSECloseReason =
|
||||
| "client-disconnect"
|
||||
| "close"
|
||||
| "error"
|
||||
| "request-aborted"
|
||||
| "send-failed"
|
||||
| "stale"
|
||||
| "superseded";
|
||||
|
||||
interface ManagedSSEConnection {
|
||||
id: number;
|
||||
clientId?: string;
|
||||
projectId?: string;
|
||||
close: (reason: SSECloseReason) => void;
|
||||
markAlive?: () => void;
|
||||
}
|
||||
|
||||
const managedConnections = new Map<number, ManagedSSEConnection>();
|
||||
|
||||
function normalizeSSEClientId(value: unknown): string | undefined {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed.length > SSE_CLIENT_ID_MAX_LENGTH) return undefined;
|
||||
if (!/^[a-zA-Z0-9._:-]+$/.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function registerManagedConnection(connection: ManagedSSEConnection): void {
|
||||
managedConnections.set(connection.id, connection);
|
||||
|
||||
if (!connection.clientId) return;
|
||||
|
||||
const superseded = Array.from(managedConnections.values()).filter((candidate) =>
|
||||
candidate.id !== connection.id &&
|
||||
candidate.clientId === connection.clientId &&
|
||||
candidate.projectId === connection.projectId
|
||||
);
|
||||
for (const existing of superseded) {
|
||||
existing.close("superseded");
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterManagedConnection(connectionId: number): void {
|
||||
managedConnections.delete(connectionId);
|
||||
}
|
||||
|
||||
export function disconnectSSEClient(clientId: unknown, projectId?: string): number {
|
||||
const normalizedClientId = normalizeSSEClientId(clientId);
|
||||
if (!normalizedClientId) return 0;
|
||||
|
||||
const matches = Array.from(managedConnections.values()).filter((connection) =>
|
||||
connection.clientId === normalizedClientId &&
|
||||
connection.projectId === projectId
|
||||
);
|
||||
for (const connection of matches) {
|
||||
connection.close("client-disconnect");
|
||||
}
|
||||
return matches.length;
|
||||
}
|
||||
|
||||
export function markSSEClientAlive(clientId: unknown, projectId?: string): number {
|
||||
const normalizedClientId = normalizeSSEClientId(clientId);
|
||||
if (!normalizedClientId) return 0;
|
||||
|
||||
const matches = Array.from(managedConnections.values()).filter((connection) =>
|
||||
connection.clientId === normalizedClientId &&
|
||||
connection.projectId === projectId
|
||||
);
|
||||
for (const connection of matches) {
|
||||
connection.markAlive?.();
|
||||
}
|
||||
return matches.length;
|
||||
}
|
||||
|
||||
/** Returns the current number of active SSE connections. */
|
||||
export function getActiveSSEConnections(): number {
|
||||
@@ -197,9 +276,16 @@ export function createSSE(
|
||||
const { projectId } = options ?? {};
|
||||
|
||||
return (_req: Request, res: Response) => {
|
||||
const connectionId = nextConnectionId++;
|
||||
const clientId = normalizeSSEClientId(_req.query.clientId);
|
||||
const socket = res.socket ?? _req.socket;
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
// This header discourages reuse after the stream ends, but Chrome may
|
||||
// still keep an EventSource transport alive during page unload. Cleanup is
|
||||
// therefore driven by explicit client ids and server-side reaping below.
|
||||
res.setHeader("Connection", "close");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
|
||||
@@ -207,15 +293,15 @@ export function createSSE(
|
||||
// Track high water mark and log when new highs are reached
|
||||
if (activeConnections > highWaterMark) {
|
||||
highWaterMark = activeConnections;
|
||||
console.log(`[sse] active connections: ${activeConnections} (high water mark: ${highWaterMark})`);
|
||||
}
|
||||
console.log(`[sse] + connection (active=${activeConnections}, hwm=${highWaterMark})`);
|
||||
|
||||
// Send initial heartbeat
|
||||
res.write(": connected\n\n");
|
||||
|
||||
/** Write an SSE message; clean up on failure. */
|
||||
const send = (data: string) => {
|
||||
if (!safeWrite(res, data)) cleanup();
|
||||
if (!safeWrite(res, data)) cleanup("send-failed");
|
||||
};
|
||||
|
||||
// --- Event handler definitions ---
|
||||
@@ -412,11 +498,26 @@ export function createSSE(
|
||||
// --- Cleanup (all handlers are defined above, safe to reference) ---
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = () => {
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
let clientStaleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function resetClientStaleTimer(): void {
|
||||
if (!clientId) return;
|
||||
if (clientStaleTimer) clearTimeout(clientStaleTimer);
|
||||
clientStaleTimer = setTimeout(() => {
|
||||
closeConnection("stale");
|
||||
}, SSE_CLIENT_STALE_MS);
|
||||
clientStaleTimer.unref?.();
|
||||
}
|
||||
|
||||
function cleanup(_reason: SSECloseReason = "close") {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
unregisterManagedConnection(connectionId);
|
||||
activeConnections--;
|
||||
clearInterval(heartbeat);
|
||||
console.log(`[sse] - connection (active=${activeConnections})`);
|
||||
if (clientStaleTimer) clearTimeout(clientStaleTimer);
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
store.off("task:created", onCreated);
|
||||
store.off("task:moved", onMoved);
|
||||
store.off("task:updated", onUpdated);
|
||||
@@ -479,7 +580,25 @@ export function createSSE(
|
||||
chatStore.off("chat:message:added", onChatMessageAdded);
|
||||
chatStore.off("chat:message:deleted", onChatMessageDeleted);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function closeConnection(reason: SSECloseReason): void {
|
||||
cleanup(reason);
|
||||
try {
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
} catch {
|
||||
// The socket may already be gone.
|
||||
}
|
||||
try {
|
||||
if (socket && !socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup races with Node's own close path.
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subscribe ---
|
||||
|
||||
@@ -556,19 +675,42 @@ export function createSSE(
|
||||
// Sent as a named event so the client's EventSource can detect it
|
||||
// (SSE comments starting with ":" are silently consumed and never
|
||||
// fire event listeners in the browser).
|
||||
const heartbeat = setInterval(() => {
|
||||
registerManagedConnection({
|
||||
id: connectionId,
|
||||
clientId,
|
||||
projectId,
|
||||
close: closeConnection,
|
||||
markAlive: resetClientStaleTimer,
|
||||
});
|
||||
resetClientStaleTimer();
|
||||
|
||||
heartbeat = setInterval(() => {
|
||||
send("event: heartbeat\ndata: \n\n");
|
||||
}, 30_000);
|
||||
|
||||
// Register cleanup on request close (primary path for HTTP/1.1)
|
||||
_req.on("close", cleanup);
|
||||
_req.on("close", () => cleanup("close"));
|
||||
_req.on("aborted", () => closeConnection("request-aborted"));
|
||||
|
||||
// Also register on response close as a safety net for edge cases
|
||||
// (e.g., proxy timeouts, HTTP/2 stream resets). This ensures cleanup
|
||||
// fires even if the request object doesn't emit "close".
|
||||
// Guard with typeof check for test mocks that may not have on method.
|
||||
if (typeof res.on === "function") {
|
||||
res.on("close", cleanup);
|
||||
res.on("close", () => cleanup("close"));
|
||||
}
|
||||
|
||||
// Socket events still handle normal disconnects and low-level errors. The
|
||||
// client-id registry above covers browser unload cases where Chrome keeps
|
||||
// the HTTP/1.1 transport alive and no close event arrives promptly.
|
||||
if (socket) {
|
||||
if (typeof socket.setKeepAlive === "function") {
|
||||
socket.setKeepAlive(true, 10_000);
|
||||
}
|
||||
if (typeof socket.on === "function") {
|
||||
socket.on("close", () => cleanup("close"));
|
||||
socket.on("error", () => closeConnection("error"));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user