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 { MailboxView } from "./components/MailboxView";
|
||||||
import { PageErrorBoundary } from "./components/ErrorBoundary";
|
import { PageErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { AppModals } from "./components/AppModals";
|
import { AppModals } from "./components/AppModals";
|
||||||
|
import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorPage";
|
||||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||||
@@ -95,7 +96,7 @@ function AppInner() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Project management hooks - MUST be called before any conditional logic
|
// 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();
|
const { nodes } = useNodes();
|
||||||
|
|
||||||
// Node context for local/remote node switching - must be called before useCurrentProject
|
// 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
|
// View state must be defined before useTasks since useTasks depends on taskView for SSE gating
|
||||||
const { viewMode, setViewMode, taskView, handleChangeTaskView } = useViewState({
|
const { viewMode, setViewMode, taskView, handleChangeTaskView } = useViewState({
|
||||||
projectsLoading,
|
projectsLoading,
|
||||||
|
projectsError,
|
||||||
currentProjectLoading,
|
currentProjectLoading,
|
||||||
currentProject,
|
currentProject,
|
||||||
projectsLength: projects.length,
|
projectsLength: projects.length,
|
||||||
@@ -279,6 +281,7 @@ function AppInner() {
|
|||||||
|
|
||||||
// Nodes management is an overlay view (not a modal), so it stays local to App.
|
// Nodes management is an overlay view (not a modal), so it stays local to App.
|
||||||
const [nodesOpen, setNodesOpen] = useState(false);
|
const [nodesOpen, setNodesOpen] = useState(false);
|
||||||
|
const [retryingProjects, setRetryingProjects] = useState(false);
|
||||||
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
|
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
|
||||||
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
|
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
|
||||||
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
|
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
|
||||||
@@ -431,6 +434,15 @@ function AppInner() {
|
|||||||
setNodesOpen((prev) => !prev);
|
setNodesOpen((prev) => !prev);
|
||||||
}, [nodesEnabled]);
|
}, [nodesEnabled]);
|
||||||
|
|
||||||
|
const handleRetryProjects = useCallback(async () => {
|
||||||
|
setRetryingProjects(true);
|
||||||
|
try {
|
||||||
|
await refreshProjects();
|
||||||
|
} finally {
|
||||||
|
setRetryingProjects(false);
|
||||||
|
}
|
||||||
|
}, [refreshProjects]);
|
||||||
|
|
||||||
const handleOpenMission = useCallback((missionId: string) => {
|
const handleOpenMission = useCallback((missionId: string) => {
|
||||||
setMissionTargetId(missionId);
|
setMissionTargetId(missionId);
|
||||||
setMissionResumeSessionId(undefined);
|
setMissionResumeSessionId(undefined);
|
||||||
@@ -463,8 +475,25 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
}, [bgDismiss, sessionsNeedingInput]);
|
}, [bgDismiss, sessionsNeedingInput]);
|
||||||
|
|
||||||
|
const showBackendConnectionErrorPage =
|
||||||
|
!projectsLoading &&
|
||||||
|
!currentProjectLoading &&
|
||||||
|
projects.length === 0 &&
|
||||||
|
!currentProject &&
|
||||||
|
Boolean(projectsError);
|
||||||
|
|
||||||
// Render main content based on view mode
|
// Render main content based on view mode
|
||||||
const renderMainContent = () => {
|
const renderMainContent = () => {
|
||||||
|
if (showBackendConnectionErrorPage) {
|
||||||
|
return (
|
||||||
|
<BackendConnectionErrorPage
|
||||||
|
errorMessage={projectsError ?? "Failed to fetch projects"}
|
||||||
|
isRetrying={retryingProjects}
|
||||||
|
onRetry={handleRetryProjects}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (nodesOpen) {
|
if (nodesOpen) {
|
||||||
return (
|
return (
|
||||||
<div className="nodes-management-overlay">
|
<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
|
// Mock state holders for dynamic mocking
|
||||||
|
const mockRefreshProjects = vi.fn(async () => {});
|
||||||
|
|
||||||
const mockProjectsState = {
|
const mockProjectsState = {
|
||||||
projects: [] as any[],
|
projects: [] as any[],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROJECT_ID = "proj_123";
|
const DEFAULT_PROJECT_ID = "proj_123";
|
||||||
@@ -185,8 +188,8 @@ vi.mock("../../hooks/useProjects", () => ({
|
|||||||
useProjects: () => ({
|
useProjects: () => ({
|
||||||
projects: mockProjectsState.projects,
|
projects: mockProjectsState.projects,
|
||||||
loading: mockProjectsState.loading,
|
loading: mockProjectsState.loading,
|
||||||
error: null,
|
error: mockProjectsState.error,
|
||||||
refresh: vi.fn(),
|
refresh: mockRefreshProjects,
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
unregister: vi.fn(),
|
unregister: vi.fn(),
|
||||||
@@ -258,6 +261,9 @@ beforeEach(() => {
|
|||||||
// Reset mock states
|
// Reset mock states
|
||||||
mockProjectsState.projects = [];
|
mockProjectsState.projects = [];
|
||||||
mockProjectsState.loading = false;
|
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.currentProject = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
|
||||||
mockCurrentProjectState.setCurrentProject.mockClear();
|
mockCurrentProjectState.setCurrentProject.mockClear();
|
||||||
mockCurrentProjectState.clearCurrentProject.mockClear();
|
mockCurrentProjectState.clearCurrentProject.mockClear();
|
||||||
@@ -292,6 +298,51 @@ beforeEach(() => {
|
|||||||
mockGetStepData.mockReturnValue(null);
|
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", () => {
|
describe("App mailbox unread count", () => {
|
||||||
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
|
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|||||||
@@ -2267,9 +2267,11 @@ describe("ModelOnboardingModal", () => {
|
|||||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The model dropdown should be pre-populated with the saved default
|
// Wait for async model/settings hydration before asserting selected value
|
||||||
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
|
const dropdown = await screen.findByTestId("mock-model-dropdown") as HTMLSelectElement;
|
||||||
expect(dropdown.value).toBe("anthropic/claude-sonnet-4-5");
|
await waitFor(() => {
|
||||||
|
expect(dropdown.value).toBe("anthropic/claude-sonnet-4-5");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves selectedModel empty when no default is configured in global settings", async () => {
|
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
|
// 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("");
|
expect(dropdown.value).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2300,7 +2302,7 @@ describe("ModelOnboardingModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The modal should still render with empty dropdown
|
// 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("");
|
expect(dropdown.value).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const PROJECT: ProjectInfo = {
|
|||||||
function createOptions(overrides: Partial<Parameters<typeof useViewState>[0]> = {}): Parameters<typeof useViewState>[0] {
|
function createOptions(overrides: Partial<Parameters<typeof useViewState>[0]> = {}): Parameters<typeof useViewState>[0] {
|
||||||
return {
|
return {
|
||||||
projectsLoading: false,
|
projectsLoading: false,
|
||||||
|
projectsError: null,
|
||||||
currentProjectLoading: false,
|
currentProjectLoading: false,
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
projectsLength: 1,
|
projectsLength: 1,
|
||||||
@@ -189,6 +190,29 @@ describe("useViewState", () => {
|
|||||||
vi.useRealTimers();
|
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 ─────────────────────────────────────
|
// ── Insights view persistence ─────────────────────────────────────
|
||||||
|
|
||||||
it("reads saved insights taskView from scoped localStorage on init", async () => {
|
it("reads saved insights taskView from scoped localStorage on init", async () => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function normalizeTaskView(value: TaskView): TaskView {
|
|||||||
|
|
||||||
interface UseViewStateOptions {
|
interface UseViewStateOptions {
|
||||||
projectsLoading: boolean;
|
projectsLoading: boolean;
|
||||||
|
projectsError: string | null;
|
||||||
currentProjectLoading: boolean;
|
currentProjectLoading: boolean;
|
||||||
currentProject: ProjectInfo | null;
|
currentProject: ProjectInfo | null;
|
||||||
projectsLength: number;
|
projectsLength: number;
|
||||||
@@ -53,6 +54,7 @@ export interface UseViewStateResult {
|
|||||||
export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||||
const {
|
const {
|
||||||
projectsLoading,
|
projectsLoading,
|
||||||
|
projectsError,
|
||||||
currentProjectLoading,
|
currentProjectLoading,
|
||||||
currentProject,
|
currentProject,
|
||||||
projectsLength,
|
projectsLength,
|
||||||
@@ -112,6 +114,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectsLoading || currentProjectLoading) return;
|
if (projectsLoading || currentProjectLoading) return;
|
||||||
if (setupWizardOpen) return;
|
if (setupWizardOpen) return;
|
||||||
|
if (projectsError) return;
|
||||||
if (projectsLength > 0 || currentProject) return;
|
if (projectsLength > 0 || currentProject) return;
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -121,6 +124,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [
|
}, [
|
||||||
projectsLoading,
|
projectsLoading,
|
||||||
|
projectsError,
|
||||||
projectsLength,
|
projectsLength,
|
||||||
currentProjectLoading,
|
currentProjectLoading,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
|||||||
Reference in New Issue
Block a user