diff --git a/.changeset/dispose-spawned-child-sessions.md b/.changeset/dispose-spawned-child-sessions.md index 9c37f6a0dd..60db55d146 100644 --- a/.changeset/dispose-spawned-child-sessions.md +++ b/.changeset/dispose-spawned-child-sessions.md @@ -2,4 +2,4 @@ "@runfusion/fusion": patch --- -Dispose completed spawned child agent sessions so execution memory is released promptly after `fn_spawn_agent` children finish, keep artifact registry listing metadata-only so large inline artifacts are not loaded during agent execution, and bound structured tool-result log previews before serialization. +Dispose completed spawned child agent sessions so execution memory is released promptly after `fn_spawn_agent` children finish, keep artifact registry listing metadata-only so large inline artifacts are not loaded during agent execution, bound structured tool-result log previews before serialization, and reduce dashboard SSE keepalive churn. diff --git a/packages/dashboard/app/__tests__/sse-bus.test.ts b/packages/dashboard/app/__tests__/sse-bus.test.ts index 4ce140d9ba..76e5e9d299 100644 --- a/packages/dashboard/app/__tests__/sse-bus.test.ts +++ b/packages/dashboard/app/__tests__/sse-bus.test.ts @@ -182,6 +182,36 @@ describe("sse-bus", () => { expect(MockEventSource.instances.length).toBe(countBeforeTimers); }); + it("does not storm keepalive control requests for active local event streams", () => { + vi.useFakeTimers(); + const originalFetch = window.fetch; + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))); + Object.defineProperty(window, "fetch", { + configurable: true, + writable: true, + value: fetchMock, + }); + try { + const unsub = subscribeSse("/api/events", {}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(29_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + + unsub(); + } finally { + Object.defineProperty(window, "fetch", { + configurable: true, + writable: true, + value: originalFetch, + }); + vi.useRealTimers(); + } + }); + it("reopens subscribed channel on pageshow even when event.persisted is false", () => { subscribeSse("/api/events?projectId=p1", {}); expect(MockEventSource.instances).toHaveLength(1); diff --git a/packages/dashboard/app/sse-bus.ts b/packages/dashboard/app/sse-bus.ts index a758aefdfa..dec503c767 100644 --- a/packages/dashboard/app/sse-bus.ts +++ b/packages/dashboard/app/sse-bus.ts @@ -16,8 +16,12 @@ 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; +/* + * FNXC:DashboardSSE 2026-06-23-15:08: + * Dashboard SSE keepalive exists only to let the server reap abandoned browser streams. It must not create a visible storm of regular HTTP connections when the engine is off, so keep the liveness probe infrequent and let the server stale window absorb brief tab/network stalls. + */ +const CLIENT_KEEPALIVE_INTERVAL_MS = 30_000; +const CLIENT_KEEPALIVE_TIMEOUT_MS = 5_000; const VISIBILITY_REOPEN_DEDUPE_MS = 1_000; const CLIENT_ID_STORAGE_KEY = "fusion:sse-client-id"; diff --git a/packages/dashboard/src/__tests__/sse.test.ts b/packages/dashboard/src/__tests__/sse.test.ts index b10499aceb..1f6e4e0bfe 100644 --- a/packages/dashboard/src/__tests__/sse.test.ts +++ b/packages/dashboard/src/__tests__/sse.test.ts @@ -338,7 +338,7 @@ describe("createSSE client cleanup", () => { expect(getActiveSSEConnections()).toBe(baseline + 1); - vi.advanceTimersByTime(4_999); + vi.advanceTimersByTime(74_999); expect(connection.res.end).not.toHaveBeenCalled(); expect(getActiveSSEConnections()).toBe(baseline + 1); @@ -353,14 +353,14 @@ describe("createSSE client cleanup", () => { const baseline = getActiveSSEConnections(); const connection = openSseConnection("client-five"); - vi.advanceTimersByTime(4_000); + vi.advanceTimersByTime(30_000); expect(markSSEClientAlive("client-five")).toBe(1); - vi.advanceTimersByTime(4_000); + vi.advanceTimersByTime(74_999); expect(connection.res.end).not.toHaveBeenCalled(); expect(getActiveSSEConnections()).toBe(baseline + 1); - vi.advanceTimersByTime(1_000); + vi.advanceTimersByTime(1); expect(connection.res.end).toHaveBeenCalledTimes(1); expect(getActiveSSEConnections()).toBe(baseline); }); diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index c33b9c72a5..54415cc477 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -963,7 +963,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT // attach to the same EventEmitter instance that the engine writes to, // rather than a separate store created by getOrCreateProjectStore. let scopedStore: TaskStore; - let agentStore; + let agentStore: AgentStore | undefined; let messageStore: MessageStore | undefined; let automationStore: AutomationStore | undefined; let scopedChatStore = chatStore; diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index 9c51e40a86..7f7111c3d6 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -19,7 +19,11 @@ let highWaterMark = 0; let nextConnectionId = 1; const SSE_CLIENT_ID_MAX_LENGTH = 128; -const SSE_CLIENT_STALE_MS = 5_000; +/* + * FNXC:DashboardSSE 2026-06-23-15:08: + * Client-side keepalive probes are intentionally infrequent to avoid a dashboard-only HTTP connection storm. Keep the server stale timer comfortably above that cadence so healthy streams are not reaped between probes while abandoned streams still self-clean. + */ +const SSE_CLIENT_STALE_MS = 75_000; // If a client's outbound buffer exceeds this, treat the connection as stuck // and close it. Without this, res.write() silently queues into res.outputData // for a paused/backgrounded client, and every store event for every entity