fix(dashboard): close SSE connections on outbound backpressure

The global SSE broadcast called res.write() without checking the return
value, so a paused or backgrounded client would silently accumulate
every store event for every entity (tasks, missions, plugins, agents,
chat, ...) into res.outputData until the dashboard process OOMed.
Add a 4 MB writableLength threshold; when exceeded, tear down the
connection so the OS releases the buffer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-24 22:02:40 -07:00
committed by gsxdsm
parent 4b95ada290
commit bafad3d45a
6 changed files with 142 additions and 8 deletions

View File

@@ -0,0 +1,24 @@
interface BackendConnectionErrorPageProps {
errorMessage: string;
isRetrying: boolean;
onRetry: () => void;
}
export function BackendConnectionErrorPage({
errorMessage,
isRetrying,
onRetry,
}: BackendConnectionErrorPageProps) {
return (
<div className="project-overview-empty" role="alert" aria-live="polite">
<h2>Can&apos;t reach the Fusion backend</h2>
<p className="settings-muted">
Fusion couldn&apos;t load your projects right now. Please make sure the backend is running and try again.
</p>
<p className="settings-muted">Error: {errorMessage}</p>
<button type="button" className="btn btn-primary" onClick={onRetry} disabled={isRetrying}>
{isRetrying ? "Retrying…" : "Retry Connection"}
</button>
</div>
);
}

View File

@@ -165,9 +165,12 @@ vi.mock("../../components/CustomModelDropdown", () => ({
}));
// Mock state holders for dynamic mocking
const mockRefreshProjects = vi.fn(async () => {});
const mockProjectsState = {
projects: [] as any[],
loading: false,
error: null as string | null,
};
const DEFAULT_PROJECT_ID = "proj_123";
@@ -185,8 +188,8 @@ vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({
projects: mockProjectsState.projects,
loading: mockProjectsState.loading,
error: null,
refresh: vi.fn(),
error: mockProjectsState.error,
refresh: mockRefreshProjects,
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
@@ -258,6 +261,9 @@ beforeEach(() => {
// Reset mock states
mockProjectsState.projects = [];
mockProjectsState.loading = false;
mockProjectsState.error = null;
mockRefreshProjects.mockReset();
mockRefreshProjects.mockImplementation(async () => {});
mockCurrentProjectState.currentProject = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
mockCurrentProjectState.setCurrentProject.mockClear();
mockCurrentProjectState.clearCurrentProject.mockClear();
@@ -292,6 +298,51 @@ beforeEach(() => {
mockGetStepData.mockReturnValue(null);
});
describe("App backend-unreachable first-run flow", () => {
it("renders backend connection error page instead of setup wizard when projects fetch fails during first-run", async () => {
mockProjectsState.projects = [];
mockProjectsState.error = "Backend unavailable";
mockCurrentProjectState.currentProject = null;
render(<App />);
await waitFor(() => {
expect(screen.getByText("Can't reach the Fusion backend")).toBeTruthy();
});
expect(screen.getByRole("button", { name: "Retry Connection" })).toBeTruthy();
expect(screen.queryByText("Welcome to Fusion")).toBeNull();
});
it("retries project loading and resumes setup wizard flow after connectivity recovers", async () => {
mockProjectsState.projects = [];
mockProjectsState.error = "Backend unavailable";
mockCurrentProjectState.currentProject = null;
mockRefreshProjects.mockImplementation(async () => {
mockProjectsState.error = null;
});
const { rerender } = render(<App />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Retry Connection" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Retry Connection" }));
await waitFor(() => {
expect(mockRefreshProjects).toHaveBeenCalledTimes(1);
});
rerender(<App />);
await waitFor(() => {
expect(screen.getByText("Welcome to Fusion")).toBeTruthy();
});
});
});
describe("App mailbox unread count", () => {
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

View File

@@ -2267,9 +2267,11 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// The model dropdown should be pre-populated with the saved default
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(dropdown.value).toBe("anthropic/claude-sonnet-4-5");
// Wait for async model/settings hydration before asserting selected value
const dropdown = await screen.findByTestId("mock-model-dropdown") as HTMLSelectElement;
await waitFor(() => {
expect(dropdown.value).toBe("anthropic/claude-sonnet-4-5");
});
});
it("leaves selectedModel empty when no default is configured in global settings", async () => {
@@ -2285,7 +2287,7 @@ describe("ModelOnboardingModal", () => {
});
// The model dropdown should be empty
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
const dropdown = await screen.findByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(dropdown.value).toBe("");
});
@@ -2300,7 +2302,7 @@ describe("ModelOnboardingModal", () => {
});
// The modal should still render with empty dropdown
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
const dropdown = await screen.findByTestId("mock-model-dropdown") as HTMLSelectElement;
expect(dropdown.value).toBe("");
});