fix(dashboard): reduce SSE keepalive churn
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user