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:
@@ -8,6 +8,7 @@ import { MissionManager } from "./components/MissionManager";
|
||||
import { MailboxView } from "./components/MailboxView";
|
||||
import { PageErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { AppModals } from "./components/AppModals";
|
||||
import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorPage";
|
||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||
@@ -95,7 +96,7 @@ function AppInner() {
|
||||
}, []);
|
||||
|
||||
// Project management hooks - MUST be called before any conditional logic
|
||||
const { projects, loading: projectsLoading, refresh: refreshProjects } = useProjects();
|
||||
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects } = useProjects();
|
||||
const { nodes } = useNodes();
|
||||
|
||||
// Node context for local/remote node switching - must be called before useCurrentProject
|
||||
@@ -164,6 +165,7 @@ function AppInner() {
|
||||
// View state must be defined before useTasks since useTasks depends on taskView for SSE gating
|
||||
const { viewMode, setViewMode, taskView, handleChangeTaskView } = useViewState({
|
||||
projectsLoading,
|
||||
projectsError,
|
||||
currentProjectLoading,
|
||||
currentProject,
|
||||
projectsLength: projects.length,
|
||||
@@ -279,6 +281,7 @@ function AppInner() {
|
||||
|
||||
// Nodes management is an overlay view (not a modal), so it stays local to App.
|
||||
const [nodesOpen, setNodesOpen] = useState(false);
|
||||
const [retryingProjects, setRetryingProjects] = useState(false);
|
||||
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
|
||||
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
|
||||
@@ -431,6 +434,15 @@ function AppInner() {
|
||||
setNodesOpen((prev) => !prev);
|
||||
}, [nodesEnabled]);
|
||||
|
||||
const handleRetryProjects = useCallback(async () => {
|
||||
setRetryingProjects(true);
|
||||
try {
|
||||
await refreshProjects();
|
||||
} finally {
|
||||
setRetryingProjects(false);
|
||||
}
|
||||
}, [refreshProjects]);
|
||||
|
||||
const handleOpenMission = useCallback((missionId: string) => {
|
||||
setMissionTargetId(missionId);
|
||||
setMissionResumeSessionId(undefined);
|
||||
@@ -463,8 +475,25 @@ function AppInner() {
|
||||
}
|
||||
}, [bgDismiss, sessionsNeedingInput]);
|
||||
|
||||
const showBackendConnectionErrorPage =
|
||||
!projectsLoading &&
|
||||
!currentProjectLoading &&
|
||||
projects.length === 0 &&
|
||||
!currentProject &&
|
||||
Boolean(projectsError);
|
||||
|
||||
// Render main content based on view mode
|
||||
const renderMainContent = () => {
|
||||
if (showBackendConnectionErrorPage) {
|
||||
return (
|
||||
<BackendConnectionErrorPage
|
||||
errorMessage={projectsError ?? "Failed to fetch projects"}
|
||||
isRetrying={retryingProjects}
|
||||
onRetry={handleRetryProjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodesOpen) {
|
||||
return (
|
||||
<div className="nodes-management-overlay">
|
||||
|
||||
@@ -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't reach the Fusion backend</h2>
|
||||
<p className="settings-muted">
|
||||
Fusion couldn'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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const PROJECT: ProjectInfo = {
|
||||
function createOptions(overrides: Partial<Parameters<typeof useViewState>[0]> = {}): Parameters<typeof useViewState>[0] {
|
||||
return {
|
||||
projectsLoading: false,
|
||||
projectsError: null,
|
||||
currentProjectLoading: false,
|
||||
currentProject: null,
|
||||
projectsLength: 1,
|
||||
@@ -189,6 +190,29 @@ describe("useViewState", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does NOT call openSetupWizard when the initial projects fetch failed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const openSetupWizard = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
projectsLength: 0,
|
||||
currentProject: null,
|
||||
projectsError: "Failed to fetch projects",
|
||||
openSetupWizard,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(openSetupWizard).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Insights view persistence ─────────────────────────────────────
|
||||
|
||||
it("reads saved insights taskView from scoped localStorage on init", async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ function normalizeTaskView(value: TaskView): TaskView {
|
||||
|
||||
interface UseViewStateOptions {
|
||||
projectsLoading: boolean;
|
||||
projectsError: string | null;
|
||||
currentProjectLoading: boolean;
|
||||
currentProject: ProjectInfo | null;
|
||||
projectsLength: number;
|
||||
@@ -53,6 +54,7 @@ export interface UseViewStateResult {
|
||||
export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
const {
|
||||
projectsLoading,
|
||||
projectsError,
|
||||
currentProjectLoading,
|
||||
currentProject,
|
||||
projectsLength,
|
||||
@@ -112,6 +114,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
useEffect(() => {
|
||||
if (projectsLoading || currentProjectLoading) return;
|
||||
if (setupWizardOpen) return;
|
||||
if (projectsError) return;
|
||||
if (projectsLength > 0 || currentProject) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -121,6 +124,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
projectsLoading,
|
||||
projectsError,
|
||||
projectsLength,
|
||||
currentProjectLoading,
|
||||
currentProject,
|
||||
|
||||
Reference in New Issue
Block a user