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 b478c252e2
commit 8d985c468c
5 changed files with 520 additions and 12 deletions

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;