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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user