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:
Aron Prins
2026-04-24 11:09:29 +02:00
parent 8c3aef9526
commit 51d1b78eeb
5 changed files with 520 additions and 12 deletions

View File

@@ -1904,6 +1904,23 @@ export function createTasksFromPlanning(
type StreamConnectionState = "connected" | "reconnecting";
// Track every live createResilientEventSource instance so we can close their
// underlying EventSource sockets on page unload. Without this, Chrome holds
// the HTTP/1.1 sockets open in its keep-alive pool across refreshes, exhausts
// its 6-per-origin limit after ~3 refreshes, and every new fetch stalls —
// leaving the dashboard frozen on "Initializing...". sse-bus.ts has its own
// handler; this one covers the parallel EventSource path in api.ts.
const activeResilientEventSources = new Set<{ close: () => void }>();
if (typeof window !== "undefined") {
const closeAll = () => {
for (const handle of Array.from(activeResilientEventSources)) {
try { handle.close(); } catch { /* best effort */ }
}
};
window.addEventListener("pagehide", closeAll);
window.addEventListener("beforeunload", closeAll);
}
interface ResilientEventSourceOptions {
maxReconnectAttempts?: number;
onConnectionStateChange?: (state: StreamConnectionState) => void;
@@ -2019,7 +2036,7 @@ function createResilientEventSource(
connect();
return {
const handle = {
close: () => {
closedByUser = true;
if (reconnectTimer) {
@@ -2027,9 +2044,12 @@ function createResilientEventSource(
reconnectTimer = null;
}
eventSource?.close();
activeResilientEventSources.delete(handle);
},
isConnected: () => !closedByUser && eventSource?.readyState === EventSource.OPEN,
};
activeResilientEventSources.add(handle);
return handle;
}
export interface DevServerCandidate {

View File

@@ -14,6 +14,11 @@ type OpenListener = () => void;
const HEARTBEAT_TIMEOUT_MS = 45_000;
const RECONNECT_DELAY_MS = 3_000;
const CLIENT_KEEPALIVE_INTERVAL_MS = 2_000;
const CLIENT_KEEPALIVE_TIMEOUT_MS = 1_500;
const CLIENT_ID_STORAGE_KEY = "fusion:sse-client-id";
let memoryClientId: string | null = null;
interface Subscriber {
events: Map<string, Set<MessageListener>>;
@@ -28,6 +33,7 @@ interface Channel {
subscribers: Set<Subscriber>;
nativeListeners: Map<string, (event: Event) => void>;
heartbeatTimer: ReturnType<typeof setTimeout> | null;
keepaliveTimer: number | null;
reconnectTimer: ReturnType<typeof setTimeout> | null;
hasOpenedOnce: boolean;
/** Set true at the start of closeChannel to prevent reconnect after teardown. */
@@ -36,6 +42,176 @@ interface Channel {
const channels = new Map<string, Channel>();
function createClientId(): string {
const cryptoApi = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
return cryptoApi.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function getSseClientId(): string | undefined {
if (typeof window === "undefined") return undefined;
if (memoryClientId) return memoryClientId;
try {
const stored = window.sessionStorage.getItem(CLIENT_ID_STORAGE_KEY);
if (stored) {
memoryClientId = stored;
return stored;
}
const created = createClientId();
window.sessionStorage.setItem(CLIENT_ID_STORAGE_KEY, created);
memoryClientId = created;
return created;
} catch {
memoryClientId = createClientId();
return memoryClientId;
}
}
function parseDashboardUrl(url: string): { parsed: URL; preserveRelativePath: boolean } | undefined {
if (typeof window === "undefined") return undefined;
try {
const parsed = new URL(url, window.location.origin);
return { parsed, preserveRelativePath: url.startsWith("/") };
} catch {
return undefined;
}
}
function isLocalEventsUrl(parsed: URL): boolean {
return parsed.origin === window.location.origin && parsed.pathname === "/api/events";
}
function appendClientIdQuery(url: string): string {
const clientId = getSseClientId();
if (!clientId) return url;
const parsed = parseDashboardUrl(url);
if (!parsed || !isLocalEventsUrl(parsed.parsed)) return url;
parsed.parsed.searchParams.set("clientId", clientId);
return parsed.preserveRelativePath
? `${parsed.parsed.pathname}${parsed.parsed.search}${parsed.parsed.hash}`
: parsed.parsed.toString();
}
function createControlUrl(eventsUrl: string, action: "disconnect" | "keepalive"): string | undefined {
const clientId = getSseClientId();
if (!clientId) return undefined;
const parsed = parseDashboardUrl(eventsUrl);
if (!parsed || !isLocalEventsUrl(parsed.parsed)) return undefined;
const controlUrl = new URL(`/api/events/${action}`, window.location.origin);
controlUrl.searchParams.set("clientId", clientId);
const projectId = parsed.parsed.searchParams.get("projectId");
if (projectId) {
controlUrl.searchParams.set("projectId", projectId);
}
return appendTokenQuery(`${controlUrl.pathname}${controlUrl.search}${controlUrl.hash}`);
}
function sendDisconnectBeacon(channel: Channel): void {
if (typeof window === "undefined") return;
const url = createControlUrl(channel.url, "disconnect");
if (!url) return;
const sendBeacon = window.navigator?.sendBeacon?.bind(window.navigator);
if (sendBeacon && sendBeacon(url)) {
return;
}
if (typeof window.fetch === "function") {
void window.fetch(url, { method: "POST", keepalive: true }).catch(() => {
// The next successful EventSource connection with this client id also
// supersedes older server-side streams, so a missed unload beacon is OK.
});
}
}
function stopClientKeepalive(channel: Channel): void {
if (channel.keepaliveTimer) {
clearInterval(channel.keepaliveTimer);
channel.keepaliveTimer = null;
}
}
function sendClientKeepalive(channel: Channel): void {
if (typeof window === "undefined" || typeof window.fetch !== "function") return;
const url = createControlUrl(channel.url, "keepalive");
if (!url) return;
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeout = controller
? window.setTimeout(() => controller.abort(), CLIENT_KEEPALIVE_TIMEOUT_MS)
: null;
void window.fetch(url, {
method: "POST",
cache: "no-store",
signal: controller?.signal,
}).catch(() => {
// If this page is suspended or the network drops, the server-side stale
// timer will reap the stream and EventSource will reconnect later.
}).finally(() => {
if (timeout !== null) {
window.clearTimeout(timeout);
}
});
}
function startClientKeepalive(channel: Channel): void {
stopClientKeepalive(channel);
if (!createControlUrl(channel.url, "keepalive")) return;
sendClientKeepalive(channel);
channel.keepaliveTimer = window.setInterval(() => {
sendClientKeepalive(channel);
}, CLIENT_KEEPALIVE_INTERVAL_MS);
}
// Close every EventSource when the page is unloading. Without this,
// browsers keep the underlying TCP sockets open in their HTTP/1.1
// keep-alive pool even though the JS EventSource object is gone —
// the server never sees a close, connections pile up, and within a
// few refreshes the browser hits its 6-connection-per-origin limit
// and every subsequent fetch stalls. Using `pagehide` (fires reliably
// on bfcache navigations too) plus `beforeunload` as a fallback.
if (typeof window !== "undefined") {
const closeAllChannels = () => {
for (const channel of Array.from(channels.values())) {
if (channel.closed) continue;
stopClientKeepalive(channel);
sendDisconnectBeacon(channel);
if (channel.es) {
try {
channel.es.close();
} catch {
// ignore
}
channel.es = null;
}
channel.closed = true;
}
};
const reopenPersistedChannels = (event: PageTransitionEvent) => {
if (!event.persisted) return;
for (const channel of Array.from(channels.values())) {
if (channel.subscribers.size === 0) continue;
channel.closed = false;
openChannel(channel);
}
};
window.addEventListener("pagehide", closeAllChannels);
window.addEventListener("beforeunload", closeAllChannels);
window.addEventListener("pageshow", reopenPersistedChannels);
}
function resetHeartbeat(channel: Channel): void {
if (channel.heartbeatTimer) clearTimeout(channel.heartbeatTimer);
channel.heartbeatTimer = setTimeout(() => {
@@ -52,6 +228,7 @@ function forceReconnect(channel: Channel): void {
channel.es.close();
channel.es = null;
}
stopClientKeepalive(channel);
channel.nativeListeners.clear();
if (channel.closed) return;
@@ -91,8 +268,9 @@ function openChannel(channel: Channel): void {
// EventSource can't set custom headers, so the bearer token must ride on
// the URL as `fn_token=<token>`. `appendTokenQuery` is a no-op when no
// token is configured.
const es = new EventSource(appendTokenQuery(channel.url));
const es = new EventSource(appendTokenQuery(appendClientIdQuery(channel.url)));
channel.es = es;
startClientKeepalive(channel);
es.addEventListener("open", () => {
resetHeartbeat(channel);
@@ -146,6 +324,7 @@ function reattachNativeListeners(channel: Channel): void {
function closeChannel(channel: Channel): void {
channel.closed = true;
if (channel.heartbeatTimer) clearTimeout(channel.heartbeatTimer);
stopClientKeepalive(channel);
if (channel.reconnectTimer) clearTimeout(channel.reconnectTimer);
if (channel.es) channel.es.close();
channel.es = null;
@@ -178,6 +357,7 @@ export function subscribeSse(url: string, sub: SseSubscription = {}): () => void
subscribers: new Set(),
nativeListeners: new Map(),
heartbeatTimer: null,
keepaliveTimer: null,
reconnectTimer: null,
hasOpenedOnce: false,
closed: false,
@@ -220,6 +400,7 @@ export function subscribeSse(url: string, sub: SseSubscription = {}): () => void
/** Test-only: tear down every open channel. */
export function __resetSseBus(): void {
for (const channel of Array.from(channels.values())) closeChannel(channel);
memoryClientId = null;
}
/** Test-only: inspect the number of live channels. */

View File

@@ -9,7 +9,7 @@ import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore, Centr
import { AgentStore, ChatStore } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
import { createSSE, disconnectSSEClient, markSSEClientAlive } from "./sse.js";
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
import { ApiError, sendErrorResponse } from "./api-error.js";
import { getOrCreateProjectStore, evictAllProjectStores, setOnProjectFirstCreated } from "./project-store-resolver.js";
@@ -449,6 +449,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const mutationRateLimit = rateLimit(RATE_LIMITS.mutation);
const setupRateLimit = rateLimit(RATE_LIMITS.api);
const setupReadRateLimit = rateLimit(RATE_LIMITS.api);
const sseControlRateLimit = rateLimit({ windowMs: 60_000, max: 300 });
// Raw body buffer for webhook signature verification - must be before express.json()
// Only applied to the webhook route
@@ -507,6 +508,23 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Create ChatStore for chat session management (available for SSE event forwarding)
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
// Lets the browser explicitly release server-side SSE listeners during page
// unload. EventSource.close() is not enough in Chrome refresh paths because
// the HTTP/1.1 transport can remain open in the browser network service.
app.post("/api/events/disconnect", sseControlRateLimit, (req, res) => {
const clientId = typeof req.query.clientId === "string" ? req.query.clientId : undefined;
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
disconnectSSEClient(clientId, projectId);
res.status(204).end();
});
app.post("/api/events/keepalive", sseControlRateLimit, (req, res) => {
const clientId = typeof req.query.clientId === "string" ? req.query.clientId : undefined;
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
markSSEClientAlive(clientId, projectId);
res.status(204).end();
});
// Rate limiting — stricter limit on SSE connections
app.get("/api/events", rateLimit(RATE_LIMITS.sse), async (req, res) => {
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;

View File

@@ -0,0 +1,147 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Request, Response } from "express";
import type { TaskStore } from "@fusion/core";
import { createSSE, disconnectSSEClient, getActiveSSEConnections, markSSEClientAlive } from "./sse.js";
class MockSocket extends EventEmitter {
destroyed = false;
setKeepAlive = vi.fn();
destroy = vi.fn(() => {
if (this.destroyed) return;
this.destroyed = true;
this.emit("close");
});
}
class MockResponse extends EventEmitter {
headers = new Map<string, string>();
writableEnded = false;
destroyed = false;
write = vi.fn();
flushHeaders = vi.fn();
end = vi.fn(() => {
if (this.writableEnded) return;
this.writableEnded = true;
this.emit("close");
});
constructor(readonly socket: MockSocket) {
super();
}
setHeader(name: string, value: string): void {
this.headers.set(name, value);
}
}
function createMockStore(): TaskStore {
return {
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
function openSseConnection(clientId: string, projectId?: string) {
const store = createMockStore();
const socket = new MockSocket();
const req = new EventEmitter() as Request & { query: Record<string, string>; socket: MockSocket };
req.query = projectId ? { clientId, projectId } : { clientId };
req.socket = socket;
const res = new MockResponse(socket);
createSSE(
store,
undefined,
undefined,
undefined,
projectId ? { projectId } : undefined,
)(req, res as unknown as Response);
return { req, res, socket, store };
}
afterEach(() => {
vi.useRealTimers();
});
describe("createSSE client cleanup", () => {
it("disconnectSSEClient closes and unregisters the matching stream", () => {
const baseline = getActiveSSEConnections();
const connection = openSseConnection("client-one");
expect(getActiveSSEConnections()).toBe(baseline + 1);
expect(disconnectSSEClient("client-one")).toBe(1);
expect(connection.res.end).toHaveBeenCalledTimes(1);
expect(connection.socket.destroy).toHaveBeenCalledTimes(1);
expect(getActiveSSEConnections()).toBe(baseline);
});
it("a new stream supersedes an older stream from the same client and project", () => {
const baseline = getActiveSSEConnections();
const first = openSseConnection("client-two", "project-a");
const second = openSseConnection("client-two", "project-a");
expect(first.res.end).toHaveBeenCalledTimes(1);
expect(first.socket.destroy).toHaveBeenCalledTimes(1);
expect(second.res.end).not.toHaveBeenCalled();
expect(getActiveSSEConnections()).toBe(baseline + 1);
expect(disconnectSSEClient("client-two", "project-a")).toBe(1);
expect(getActiveSSEConnections()).toBe(baseline);
});
it("keeps streams from the same client isolated by project scope", () => {
const baseline = getActiveSSEConnections();
const first = openSseConnection("client-three", "project-a");
const second = openSseConnection("client-three", "project-b");
expect(first.res.end).not.toHaveBeenCalled();
expect(second.res.end).not.toHaveBeenCalled();
expect(getActiveSSEConnections()).toBe(baseline + 2);
expect(disconnectSSEClient("client-three", "project-a")).toBe(1);
expect(first.res.end).toHaveBeenCalledTimes(1);
expect(second.res.end).not.toHaveBeenCalled();
expect(getActiveSSEConnections()).toBe(baseline + 1);
expect(disconnectSSEClient("client-three", "project-b")).toBe(1);
expect(getActiveSSEConnections()).toBe(baseline);
});
it("closes a client stream when keepalives stop", () => {
vi.useFakeTimers();
const baseline = getActiveSSEConnections();
const connection = openSseConnection("client-four");
expect(getActiveSSEConnections()).toBe(baseline + 1);
vi.advanceTimersByTime(4_999);
expect(connection.res.end).not.toHaveBeenCalled();
expect(getActiveSSEConnections()).toBe(baseline + 1);
vi.advanceTimersByTime(1);
expect(connection.res.end).toHaveBeenCalledTimes(1);
expect(connection.socket.destroy).toHaveBeenCalledTimes(1);
expect(getActiveSSEConnections()).toBe(baseline);
});
it("extends a client stream while keepalives arrive", () => {
vi.useFakeTimers();
const baseline = getActiveSSEConnections();
const connection = openSseConnection("client-five");
vi.advanceTimersByTime(4_000);
expect(markSSEClientAlive("client-five")).toBe(1);
vi.advanceTimersByTime(4_000);
expect(connection.res.end).not.toHaveBeenCalled();
expect(getActiveSSEConnections()).toBe(baseline + 1);
vi.advanceTimersByTime(1_000);
expect(connection.res.end).toHaveBeenCalledTimes(1);
expect(getActiveSSEConnections()).toBe(baseline);
});
});

View File

@@ -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"));
}
}
};
}