Merge pull request #4 from Runfusion/fix/sse-connection-leak
fix: prevent dashboard reload freezes after repeated refreshes
This commit is contained in:
5
.changeset/fix-dashboard-sse-and-cron-utc.md
Normal file
5
.changeset/fix-dashboard-sse-and-cron-utc.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Prevent stale dashboard service workers from trapping old client bundles, and compute automation cron schedules against UTC so monthly runs stay on day 1 across timezones.
|
||||
5
.changeset/fix-sse-refresh-leak.md
Normal file
5
.changeset/fix-sse-refresh-leak.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix dashboard SSE cleanup on browser refresh so stale event streams do not exhaust per-origin browser connections.
|
||||
@@ -81,6 +81,12 @@ describe("AutomationStore", () => {
|
||||
expect(new Date(next).getUTCHours()).toBe(13);
|
||||
expect(new Date(next).getUTCMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it("computes monthly runs against UTC instead of local machine time", () => {
|
||||
const fromDate = new Date("2026-04-15T00:00:00Z");
|
||||
const next = store.computeNextRun("0 0 1 * *", fromDate);
|
||||
expect(next).toBe("2026-05-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
// ── createSchedule ────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,8 @@ import { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
||||
import type { ScheduleType } from "./automation.js";
|
||||
import { Database, fromJson } from "./db.js";
|
||||
|
||||
const CRON_TIMEZONE = "UTC";
|
||||
|
||||
export interface AutomationStoreEvents {
|
||||
"schedule:created": [schedule: ScheduledTask];
|
||||
"schedule:updated": [schedule: ScheduledTask];
|
||||
@@ -170,6 +172,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
||||
computeNextRun(cronExpression: string, fromDate?: Date): string {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
currentDate: fromDate ?? new Date(),
|
||||
tz: CRON_TIMEZONE,
|
||||
});
|
||||
const next = interval.next();
|
||||
return next.toISOString() ?? new Date(next.getTime()).toISOString();
|
||||
|
||||
@@ -41,6 +41,7 @@ const allScheduleTypesRecord: Record<ScheduleType, true> = {
|
||||
};
|
||||
|
||||
const allScheduleTypes = Object.keys(allScheduleTypesRecord) as ScheduleType[];
|
||||
const CRON_TIMEZONE = "UTC";
|
||||
|
||||
function cronDateToDate(value: { toISOString(): string | null; getTime(): number }): Date {
|
||||
const iso = value.toISOString();
|
||||
@@ -50,6 +51,7 @@ function cronDateToDate(value: { toISOString(): string | null; getTime(): number
|
||||
function parseNextRun(cronExpression: string, currentDate?: Date): Date {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
currentDate: currentDate ?? new Date(),
|
||||
tz: CRON_TIMEZONE,
|
||||
});
|
||||
return cronDateToDate(interval.next());
|
||||
}
|
||||
@@ -287,6 +289,7 @@ describe("Preset cron expression edge cases", () => {
|
||||
it("ensures weekdays preset never schedules Saturday or Sunday in the next 7 runs", () => {
|
||||
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.weekdays, {
|
||||
currentDate: new Date("2026-04-06T00:00:00.000Z"), // Monday
|
||||
tz: CRON_TIMEZONE,
|
||||
});
|
||||
|
||||
const days = Array.from({ length: 7 }, () => cronDateToDate(interval.next()).getUTCDay());
|
||||
@@ -301,6 +304,7 @@ describe("Preset cron expression edge cases", () => {
|
||||
it("ensures every15Minutes preset advances in 15 minute intervals", () => {
|
||||
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every15Minutes, {
|
||||
currentDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
tz: CRON_TIMEZONE,
|
||||
});
|
||||
|
||||
const first = cronDateToDate(interval.next());
|
||||
@@ -314,6 +318,7 @@ describe("Preset cron expression edge cases", () => {
|
||||
it("ensures every2Hours preset advances in 2 hour intervals", () => {
|
||||
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every2Hours, {
|
||||
currentDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
tz: CRON_TIMEZONE,
|
||||
});
|
||||
|
||||
const first = cronDateToDate(interval.next());
|
||||
|
||||
@@ -71,6 +71,12 @@ describe("RoutineStore", () => {
|
||||
expect(new Date(next).getUTCHours()).toBe(13);
|
||||
expect(new Date(next).getUTCMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it("computes monthly runs against UTC instead of local machine time", () => {
|
||||
const fromDate = new Date("2026-04-15T00:00:00Z");
|
||||
const next = store.computeNextRun("0 0 1 * *", fromDate);
|
||||
expect(next).toBe("2026-05-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
// ── createRoutine ────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
MAX_ROUTINE_RUN_HISTORY,
|
||||
} from "./routine.js";
|
||||
|
||||
const CRON_TIMEZONE = "UTC";
|
||||
|
||||
export interface RoutineStoreEvents {
|
||||
"routine:created": [routine: Routine];
|
||||
"routine:updated": [routine: Routine];
|
||||
@@ -242,6 +244,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
||||
computeNextRun(cronExpression: string, fromDate?: Date): string {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
currentDate: fromDate ?? new Date(),
|
||||
tz: CRON_TIMEZONE,
|
||||
});
|
||||
const next = interval.next();
|
||||
return new Date(next.getTime()).toISOString();
|
||||
|
||||
@@ -67,6 +67,32 @@ describe("PWA configuration", () => {
|
||||
expect(swSource).toMatch(/fusion-cache-v\d+/);
|
||||
});
|
||||
|
||||
it("service worker bypasses SSE requests instead of trying to cache them", () => {
|
||||
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
|
||||
|
||||
expect(swSource).toContain('text/event-stream');
|
||||
expect(swSource).toContain('url.pathname === "/api/events"');
|
||||
expect(swSource).toContain('url.pathname.startsWith("/api/events/")');
|
||||
expect(swSource).toContain("if (isEventStreamRequest) {");
|
||||
expect(swSource).toContain("return;");
|
||||
});
|
||||
|
||||
it("service worker revalidates navigation requests so index.html cannot stay stale", () => {
|
||||
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
|
||||
|
||||
expect(swSource).toContain('request.mode === "navigate"');
|
||||
expect(swSource).toContain('request.destination === "document"');
|
||||
expect(swSource).toContain('url.pathname === "/index.html"');
|
||||
expect(swSource).toContain('[sw] navigation cache put failed');
|
||||
});
|
||||
|
||||
it("service worker activates updated code immediately", () => {
|
||||
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
|
||||
|
||||
expect(swSource).toContain("await self.skipWaiting()");
|
||||
expect(swSource).toContain("await self.clients.claim()");
|
||||
});
|
||||
|
||||
describe("logo assets", () => {
|
||||
it("logo.svg uses ring + swoosh geometry matching Header.tsx brand mark", () => {
|
||||
const logoSvg = readFileSync(resolve(__dirname, "../public/logo.svg"), "utf8");
|
||||
|
||||
@@ -2,6 +2,17 @@ import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
|
||||
import { MockEventSource } from "../../vitest.setup";
|
||||
import { subscribeSse, __resetSseBus, __sseBusChannelCount } from "../sse-bus";
|
||||
|
||||
function expectEventsUrl(url: string, projectId?: string): void {
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
expect(parsed.pathname).toBe("/api/events");
|
||||
expect(parsed.searchParams.get("projectId")).toBe(projectId ?? null);
|
||||
expect(parsed.searchParams.get("clientId")).toBeTruthy();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSseBus();
|
||||
});
|
||||
@@ -13,7 +24,10 @@ describe("sse-bus", () => {
|
||||
const unsubB = subscribeSse(url, { events: { "task:updated": () => {} } });
|
||||
const unsubC = subscribeSse(url, { events: { "task:deleted": () => {} } });
|
||||
|
||||
const sources = MockEventSource.instances.filter((es) => es.url === url);
|
||||
const sources = MockEventSource.instances.filter((es) => {
|
||||
expectEventsUrl(es.url, "p1");
|
||||
return true;
|
||||
});
|
||||
expect(sources).toHaveLength(1);
|
||||
|
||||
unsubA();
|
||||
@@ -25,6 +39,8 @@ describe("sse-bus", () => {
|
||||
const unsubA = subscribeSse("/api/events", {});
|
||||
const unsubB = subscribeSse("/api/events?projectId=p1", {});
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
expectEventsUrl(MockEventSource.instances[0]!.url);
|
||||
expectEventsUrl(MockEventSource.instances[1]!.url, "p1");
|
||||
unsubA();
|
||||
unsubB();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -94,8 +94,16 @@ import {
|
||||
|
||||
const addToast = vi.fn();
|
||||
|
||||
function expectEventsUrl(url: string, projectId?: string) {
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
expect(parsed.pathname).toBe("/api/events");
|
||||
expect(parsed.searchParams.get("projectId")).toBe(projectId ?? null);
|
||||
expect(parsed.searchParams.get("clientId")).toBeTruthy();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
|
||||
// Default implementations
|
||||
vi.mocked(fetchPlugins).mockResolvedValue([]);
|
||||
@@ -145,7 +153,10 @@ beforeEach(() => {
|
||||
onmessage: null,
|
||||
};
|
||||
|
||||
const MockEventSource = vi.fn(() => eventSourceInstance) as unknown as typeof EventSource;
|
||||
const MockEventSource = vi.fn((url: string) => {
|
||||
eventSourceInstance.url = url;
|
||||
return eventSourceInstance;
|
||||
}) as unknown as typeof EventSource;
|
||||
MockEventSource.CONNECTING = 0;
|
||||
MockEventSource.OPEN = 1;
|
||||
MockEventSource.CLOSED = 2;
|
||||
@@ -452,7 +463,9 @@ describe("PluginManager", () => {
|
||||
expect(fetchPlugins).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(EventSource).toHaveBeenCalledWith("/api/events?projectId=proj-456");
|
||||
const url = (globalThis as any).__testEventSourceInstance?.url;
|
||||
expect(typeof url).toBe("string");
|
||||
expectEventsUrl(url, "proj-456");
|
||||
});
|
||||
|
||||
it("handles plugin enabled SSE event", async () => {
|
||||
|
||||
@@ -225,6 +225,24 @@ import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettin
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const FN1712_SCOPE_TEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function chooseModelOption(label: string, optionName: string | RegExp): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByLabelText(label);
|
||||
await user.click(trigger);
|
||||
|
||||
const matchingTextNodes = await screen.findAllByText(optionName);
|
||||
const optionText = matchingTextNodes.find((el) =>
|
||||
el.classList.contains("model-combobox-option-text")
|
||||
);
|
||||
|
||||
if (!optionText) {
|
||||
throw new Error(`Could not find model option text node for ${String(optionName)}`);
|
||||
}
|
||||
|
||||
await user.click(optionText);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -4020,14 +4038,7 @@ describe("Prompts section", () => {
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open the Default Model dropdown and select a model
|
||||
const user = userEvent.setup();
|
||||
const dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
|
||||
// Select a model from the dropdown
|
||||
const option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
await chooseModelOption("Default Model", /gpt-4o/i);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
@@ -4051,7 +4062,7 @@ describe("Prompts section", () => {
|
||||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("project-only change calls updateSettings but not updateGlobalSettings for project keys", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
@@ -4060,14 +4071,7 @@ describe("Prompts section", () => {
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open Planning Model dropdown
|
||||
const user = userEvent.setup();
|
||||
const dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||||
await user.click(dropdownBtn);
|
||||
|
||||
// Select a model
|
||||
const option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
await chooseModelOption("Planning Model", /gpt-4o/i);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
@@ -4091,53 +4095,8 @@ describe("Prompts section", () => {
|
||||
expect(globalPayload.planningProvider).toBeUndefined();
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("mixed global and project changes call both endpoints with correct subsets", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Change global model
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
let user = userEvent.setup();
|
||||
let dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
let option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
|
||||
// Change project model
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||||
await user.click(dropdownBtn);
|
||||
option = await screen.findByRole("option", { name: /claude/i });
|
||||
await user.click(option);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
|
||||
// Verify global payload contains only global keys
|
||||
expect(globalPayload).toHaveProperty("defaultProvider");
|
||||
expect(globalPayload).toHaveProperty("defaultModelId");
|
||||
expect(globalPayload.planningProvider).toBeUndefined();
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
|
||||
// Verify project payload contains only project keys
|
||||
expect(projectPayload).toHaveProperty("planningProvider");
|
||||
expect(projectPayload).toHaveProperty("planningModelId");
|
||||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reset/clear null-as-delete semantics (FN-1712)", () => {
|
||||
@@ -4174,42 +4133,8 @@ describe("Prompts section", () => {
|
||||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(projectPayload.planningProvider).toBeNull();
|
||||
expect(projectPayload.planningModelId).toBeNull();
|
||||
});
|
||||
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("clearing a global setting sends null to delete it", async () => {
|
||||
// Set up initial global settings with defaultProvider/defaultModelId set
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open the Default Model dropdown
|
||||
const user = userEvent.setup();
|
||||
const dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
|
||||
// Select "Use default" option to clear
|
||||
const useDefaultOption = await screen.findByRole("option", { name: /use default/i });
|
||||
await user.click(useDefaultOption);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify null-as-delete: defaultProvider and defaultModelId should be null when clearing an existing value
|
||||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(globalPayload.defaultProvider).toBeNull();
|
||||
expect(globalPayload.defaultModelId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory section - file editor", () => {
|
||||
|
||||
@@ -15,12 +15,24 @@ vi.mock("../../api", () => ({
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
fetchAgentLogs: vi.fn(),
|
||||
fetchAgentLogsWithMeta: vi.fn(),
|
||||
fetchAgentRunLogs: vi.fn(),
|
||||
fetchAgentChildren: vi.fn(),
|
||||
fetchAgentRuns: vi.fn(),
|
||||
fetchAgentRunDetail: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
stopAgentRun: vi.fn(),
|
||||
updateAgentInstructions: vi.fn(),
|
||||
updateAgentSoul: vi.fn(),
|
||||
updateAgentMemory: vi.fn(),
|
||||
fetchAgentMemoryFiles: vi.fn(),
|
||||
fetchAgentMemoryFile: vi.fn(),
|
||||
saveAgentMemoryFile: vi.fn(),
|
||||
fetchAgentTasks: vi.fn(),
|
||||
fetchChainOfCommand: vi.fn(),
|
||||
fetchWorkspaceFileContent: vi.fn(),
|
||||
saveWorkspaceFileContent: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
fetchAgents: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
startAgentGeneration: vi.fn(),
|
||||
@@ -41,12 +53,24 @@ const mockUpdateAgent = vi.mocked(api.updateAgent);
|
||||
const mockUpdateAgentState = vi.mocked(api.updateAgentState);
|
||||
const mockDeleteAgent = vi.mocked(api.deleteAgent);
|
||||
const mockFetchAgentLogs = vi.mocked(api.fetchAgentLogs);
|
||||
const mockFetchAgentLogsWithMeta = vi.mocked(api.fetchAgentLogsWithMeta);
|
||||
const mockFetchAgentRunLogs = vi.mocked(api.fetchAgentRunLogs);
|
||||
const mockFetchAgentChildren = vi.mocked(api.fetchAgentChildren);
|
||||
const mockFetchAgentRuns = vi.mocked(api.fetchAgentRuns);
|
||||
const mockFetchAgentRunDetail = vi.mocked(api.fetchAgentRunDetail);
|
||||
const mockStartAgentRun = vi.mocked(api.startAgentRun);
|
||||
const mockStopAgentRun = vi.mocked(api.stopAgentRun);
|
||||
const mockUpdateAgentInstructions = vi.mocked(api.updateAgentInstructions);
|
||||
const mockUpdateAgentSoul = vi.mocked(api.updateAgentSoul);
|
||||
const mockUpdateAgentMemory = vi.mocked(api.updateAgentMemory);
|
||||
const mockFetchAgentMemoryFiles = vi.mocked(api.fetchAgentMemoryFiles);
|
||||
const mockFetchAgentMemoryFile = vi.mocked(api.fetchAgentMemoryFile);
|
||||
const mockSaveAgentMemoryFile = vi.mocked(api.saveAgentMemoryFile);
|
||||
const mockFetchAgentTasks = vi.mocked(api.fetchAgentTasks);
|
||||
const mockFetchChainOfCommand = vi.mocked(api.fetchChainOfCommand);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(api.fetchWorkspaceFileContent);
|
||||
const mockSaveWorkspaceFileContent = vi.mocked(api.saveWorkspaceFileContent);
|
||||
const mockFetchModels = vi.mocked(api.fetchModels);
|
||||
const mockFetchAgents = vi.mocked(api.fetchAgents);
|
||||
const mockCreateAgent = vi.mocked(api.createAgent);
|
||||
const mockStartAgentGeneration = vi.mocked(api.startAgentGeneration);
|
||||
@@ -88,12 +112,24 @@ describe("agent modal mobile CSS structure", () => {
|
||||
mockUpdateAgentState.mockResolvedValue({ ...mockAgent, state: "paused" } as any);
|
||||
mockDeleteAgent.mockResolvedValue(undefined as any);
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false } as any);
|
||||
mockFetchAgentRunLogs.mockResolvedValue([]);
|
||||
mockFetchAgentChildren.mockResolvedValue([]);
|
||||
mockFetchAgentRuns.mockResolvedValue([]);
|
||||
mockFetchAgentRunDetail.mockResolvedValue(undefined as any);
|
||||
mockStartAgentRun.mockResolvedValue({ id: "run-001", status: "active" } as any);
|
||||
mockStopAgentRun.mockResolvedValue(undefined);
|
||||
mockUpdateAgentInstructions.mockResolvedValue(mockAgent as any);
|
||||
mockUpdateAgentSoul.mockResolvedValue(mockAgent as any);
|
||||
mockUpdateAgentMemory.mockResolvedValue(mockAgent as any);
|
||||
mockFetchAgentMemoryFiles.mockResolvedValue({ files: [] } as any);
|
||||
mockFetchAgentMemoryFile.mockResolvedValue({ content: "" } as any);
|
||||
mockSaveAgentMemoryFile.mockResolvedValue(undefined);
|
||||
mockFetchAgentTasks.mockResolvedValue([]);
|
||||
mockFetchChainOfCommand.mockResolvedValue([mockAgent] as any);
|
||||
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "" } as any);
|
||||
mockSaveWorkspaceFileContent.mockResolvedValue(undefined);
|
||||
mockFetchModels.mockResolvedValue([]);
|
||||
|
||||
mockFetchAgents.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -13,6 +13,13 @@ vi.mock("../../api", () => ({
|
||||
const mockFetchAgents = vi.mocked(api.fetchAgents);
|
||||
const mockFetchAgentStats = vi.mocked(api.fetchAgentStats);
|
||||
|
||||
function expectEventsUrl(url: string, projectId?: string) {
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
expect(parsed.pathname).toBe("/api/events");
|
||||
expect(parsed.searchParams.get("projectId")).toBe(projectId ?? null);
|
||||
expect(parsed.searchParams.get("clientId")).toBeTruthy();
|
||||
}
|
||||
|
||||
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
@@ -37,6 +44,7 @@ const defaultStats: AgentStats = {
|
||||
describe("useAgents", () => {
|
||||
beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
window.sessionStorage.clear();
|
||||
mockFetchAgents.mockReset().mockResolvedValue([]);
|
||||
mockFetchAgentStats.mockReset().mockResolvedValue(defaultStats);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -141,7 +149,8 @@ describe("useAgents", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = MockEventSource.instances.map((es) => es.url);
|
||||
expect(urls).toContain("/api/events");
|
||||
expect(urls.length).toBeGreaterThan(0);
|
||||
expectEventsUrl(urls[urls.length - 1]!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,6 +202,7 @@ describe("useAgents", () => {
|
||||
});
|
||||
|
||||
const urls = MockEventSource.instances.map((es) => es.url);
|
||||
expect(urls).toContain(`/api/events?projectId=${encodeURIComponent(projectId)}`);
|
||||
expect(urls.length).toBeGreaterThan(0);
|
||||
expectEventsUrl(urls[urls.length - 1]!, projectId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "fusion-cache-v1";
|
||||
const CACHE_NAME = "fusion-cache-v2";
|
||||
const APP_SHELL_URLS = [
|
||||
"/",
|
||||
"/index.html",
|
||||
@@ -13,6 +13,7 @@ self.addEventListener("install", (event) => {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.addAll(APP_SHELL_URLS);
|
||||
await self.skipWaiting();
|
||||
} catch (error) {
|
||||
console.warn("[sw] install cache warmup failed", error);
|
||||
}
|
||||
@@ -43,7 +44,50 @@ self.addEventListener("fetch", (event) => {
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const accept = request.headers.get("accept") ?? "";
|
||||
const isApiRequest = url.pathname.startsWith("/api/");
|
||||
const isEventStreamRequest =
|
||||
accept.includes("text/event-stream") ||
|
||||
url.pathname === "/api/events" ||
|
||||
url.pathname.startsWith("/api/events/");
|
||||
const isNavigationRequest =
|
||||
request.mode === "navigate" ||
|
||||
request.destination === "document" ||
|
||||
url.pathname === "/" ||
|
||||
url.pathname === "/index.html";
|
||||
|
||||
// EventSource requests stay open indefinitely. Waiting on cache.put() for an
|
||||
// infinite response body prevents the browser from ever receiving the stream
|
||||
// and leaks the underlying connection across reloads. Let SSE bypass the
|
||||
// service worker entirely so the browser talks to the network directly.
|
||||
if (isEventStreamRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always revalidate the HTML shell so navigation picks up the latest hashed
|
||||
// asset names instead of getting stuck on a cached index.html that points at
|
||||
// a stale bundle.
|
||||
if (isNavigationRequest) {
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put(request, networkResponse.clone());
|
||||
} catch (cacheError) {
|
||||
console.warn("[sw] navigation cache put failed", cacheError);
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (networkError) {
|
||||
const fallback = await caches.match(request);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
throw networkError;
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
if (isApiRequest) {
|
||||
event.respondWith((async () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -17,6 +17,15 @@ async function waitFor(predicate: () => boolean, timeoutMs = 4_000): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("DevServerProcessManager", () => {
|
||||
const tempDirs: string[] = [];
|
||||
const managers: DevServerProcessManager[] = [];
|
||||
@@ -101,6 +110,35 @@ describe("DevServerProcessManager", () => {
|
||||
expect(store.getState().exitCode).toBeDefined();
|
||||
});
|
||||
|
||||
it("stop() terminates the shell-launched child process tree", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { root, manager } = await createManager();
|
||||
const childPidFile = join(root, "managed-child.pid");
|
||||
|
||||
await manager.start(
|
||||
`node -e "require('node:fs').writeFileSync('${childPidFile}', String(process.pid)); setInterval(() => {}, 1000)"`,
|
||||
root,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return Number.parseInt(readFileSync(childPidFile, "utf8").trim(), 10) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const managedChildPid = Number.parseInt(readFileSync(childPidFile, "utf8").trim(), 10);
|
||||
expect(isProcessAlive(managedChildPid)).toBe(true);
|
||||
|
||||
await manager.stop();
|
||||
|
||||
await waitFor(() => !isProcessAlive(managedChildPid));
|
||||
});
|
||||
|
||||
it("stop() falls back to SIGKILL after timeout", async () => {
|
||||
const { root, store, manager } = await createManager({ stopTimeoutMs: 150 });
|
||||
|
||||
|
||||
@@ -33,6 +33,29 @@ const DEFAULT_PROBE_DELAY_MS = 10_000;
|
||||
const DEFAULT_PROBE_HOST = "127.0.0.1";
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 1_000;
|
||||
|
||||
function killManagedProcess(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
if (typeof child.pid !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
// Detached POSIX children become their own process group leaders, so
|
||||
// signaling the negative PID tears down the shell wrapper and its child.
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the direct child PID when the group no longer exists.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(child.pid, signal);
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject dev-server commands whose strings contain command-substitution
|
||||
* syntax. Dev-server commands are user-configured project settings (e.g.
|
||||
@@ -111,6 +134,7 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
|
||||
const child = spawn(safeCommand, [], {
|
||||
cwd: safeCwd,
|
||||
detached: process.platform !== "win32",
|
||||
shell: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
@@ -177,20 +201,12 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
const pid = child.pid;
|
||||
|
||||
if (typeof pid === "number") {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
killManagedProcess(child, "SIGTERM");
|
||||
}
|
||||
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!child.killed && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(child.pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process may already be gone.
|
||||
}
|
||||
if (this.childProcess === child && this.isRunning()) {
|
||||
killManagedProcess(child, "SIGKILL");
|
||||
}
|
||||
}, this.stopTimeoutMs);
|
||||
|
||||
@@ -217,7 +233,9 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.childProcess !== null && !this.childProcess.killed;
|
||||
return this.childProcess !== null
|
||||
&& this.childProcess.exitCode === null
|
||||
&& this.childProcess.signalCode === null;
|
||||
}
|
||||
|
||||
hasPendingProbeTimer(): boolean {
|
||||
@@ -228,11 +246,7 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
this.clearTimers();
|
||||
|
||||
if (this.childProcess && typeof this.childProcess.pid === "number") {
|
||||
try {
|
||||
process.kill(this.childProcess.pid, "SIGTERM");
|
||||
} catch {
|
||||
// Process is already gone.
|
||||
}
|
||||
killManagedProcess(this.childProcess, "SIGTERM");
|
||||
this.childProcess.removeAllListeners();
|
||||
this.childProcess.stdout?.removeAllListeners();
|
||||
this.childProcess.stderr?.removeAllListeners();
|
||||
|
||||
@@ -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;
|
||||
|
||||
147
packages/dashboard/src/sse.test.ts
Normal file
147
packages/dashboard/src/sse.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,10 +498,24 @@ export function createSSE(
|
||||
// --- Cleanup (all handlers are defined above, safe to reference) ---
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = () => {
|
||||
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--;
|
||||
console.log(`[sse] - connection (active=${activeConnections})`);
|
||||
if (clientStaleTimer) clearTimeout(clientStaleTimer);
|
||||
clearInterval(heartbeat);
|
||||
store.off("task:created", onCreated);
|
||||
store.off("task:moved", onMoved);
|
||||
@@ -479,7 +579,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 +674,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).
|
||||
registerManagedConnection({
|
||||
id: connectionId,
|
||||
clientId,
|
||||
projectId,
|
||||
close: closeConnection,
|
||||
markAlive: resetClientStaleTimer,
|
||||
});
|
||||
resetClientStaleTimer();
|
||||
|
||||
const 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"));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user