Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"lz-string": "^1.5.0",
"marked": "^15.0.12",
"mermaid": "^11.12.3",
"partysocket": "1.1.10",
"partysocket": "1.1.13",
"path-to-regexp": "^8.4.0",
"plotly.js": "^3.3.1",
"pyodide": "0.27.7",
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/editor/app-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ interface Props {
connection: ConnectionStatus;
isRunning: boolean;
width: AppConfig["width"];
onReconnect?: () => void;
}

export const AppContainer: React.FC<PropsWithChildren<Props>> = ({
width,
connection,
isRunning,
children,
onReconnect,
}) => {
const connectionState = connection.state;

return (
<>
<DynamicFavicon isRunning={isRunning} />
<StatusOverlay connection={connection} isRunning={isRunning} />
<StatusOverlay
connection={connection}
isRunning={isRunning}
onReconnect={onReconnect}
/>
<PyodideLoader>
<WrappedWithSidebar>
{/** oxlint-ignore-next-line -- ID is used by other components to grab the DOM element */}
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/components/editor/header/__tests__/status.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/* Copyright 2026 Marimo. All rights reserved. */
// @vitest-environment jsdom

import { fireEvent, render } from "@testing-library/react";
import { createStore, Provider as JotaiProvider } from "jotai";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { viewStateAtom } from "@/core/mode";
import {
type ConnectionStatus,
WebSocketClosedReason,
WebSocketState,
} from "@/core/websocket/types";
import { StatusOverlay } from "../status";

function renderOverlay(
connection: ConnectionStatus,
onReconnect?: () => void,
): ReturnType<typeof render> {
const store = createStore();
store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
<JotaiProvider store={store}>
<TooltipProvider>{children}</TooltipProvider>
</JotaiProvider>
);
return render(
<StatusOverlay
connection={connection}
isRunning={false}
onReconnect={onReconnect}
/>,
{ wrapper },
);
}

describe("StatusOverlay disconnect indicator", () => {
it("invokes onReconnect when the disconnect icon is clicked", () => {
const onReconnect = vi.fn();
const { getByTestId } = renderOverlay(
{
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
reason: "kernel not found",
},
onReconnect,
);

const icon = getByTestId("disconnected-indicator") as HTMLButtonElement;
expect(icon.tagName).toBe("BUTTON");
expect(icon.disabled).toBe(false);
expect(icon.getAttribute("aria-label")).toBe("Reconnect to app");
fireEvent.click(icon);
expect(onReconnect).toHaveBeenCalledTimes(1);
});

it("renders a disabled button when no onReconnect is provided", () => {
const { getByTestId } = renderOverlay({
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
reason: "kernel not found",
});

const button = getByTestId("disconnected-indicator");
expect((button as HTMLButtonElement).disabled).toBe(true);
});

it("does not render the disconnect icon when another tab has taken over", () => {
const onReconnect = vi.fn();
const { queryByTestId } = renderOverlay(
{
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.ALREADY_RUNNING,
reason: "another browser tab is already connected to the kernel",
canTakeover: true,
},
onReconnect,
);

expect(queryByTestId("disconnected-indicator")).toBeNull();
});
});
28 changes: 22 additions & 6 deletions frontend/src/components/editor/header/status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { cn } from "@/utils/cn";
export const StatusOverlay: React.FC<{
connection: ConnectionStatus;
isRunning: boolean;
}> = ({ connection, isRunning }) => {
onReconnect?: () => void;
}> = ({ connection, isRunning, onReconnect }) => {
const { mode } = useAtomValue(viewStateAtom);
const isClosed = connection.state === WebSocketState.CLOSED;
const isOpen = connection.state === WebSocketState.OPEN;
Expand All @@ -28,7 +29,9 @@ export const StatusOverlay: React.FC<{
)}
>
{isOpen && isRunning && <RunningIcon />}
{isClosed && !connection.canTakeover && <DisconnectedIcon />}
{isClosed && !connection.canTakeover && (
<DisconnectedIcon onReconnect={onReconnect} />
)}
{isClosed && connection.canTakeover && <LockedIcon />}
</div>
</>
Expand All @@ -37,11 +40,24 @@ export const StatusOverlay: React.FC<{

const topLeftStatus = "print:hidden pointer-events-auto hover:cursor-pointer";

const DisconnectedIcon = () => (
<Tooltip content="App disconnected">
<div className={topLeftStatus}>
const DisconnectedIcon: React.FC<{ onReconnect?: () => void }> = ({
onReconnect,
}) => (
<Tooltip
content={
onReconnect ? "App disconnected β€” click to reconnect" : "App disconnected"
}
>
<button
type="button"
className={cn(topLeftStatus, "bg-transparent border-0 p-0")}
aria-label={onReconnect ? "Reconnect to app" : "App disconnected"}
data-testid="disconnected-indicator"
onClick={onReconnect}
disabled={!onReconnect}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
>
<UnlinkIcon className="w-[25px] h-[25px] text-(--red-11)" />
</div>
</button>
Comment thread
bestvibes marked this conversation as resolved.
Outdated
</Tooltip>
);

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/core/edit-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const EditApp: React.FC<AppProps> = ({
};
}, []);

const { connection } = useMarimoKernelConnection({
const { connection, reconnect } = useMarimoKernelConnection({
autoInstantiate: userConfig.runtime.auto_instantiate,
setCells: (cells, layout) => {
setCells(cells);
Expand Down Expand Up @@ -146,6 +146,7 @@ export const EditApp: React.FC<AppProps> = ({
connection={connection}
isRunning={isRunning}
width={appConfig.width}
onReconnect={reconnect}
>
<AppHeader
connection={connection}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/core/run-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
};
}, []);

const { connection } = useMarimoKernelConnection({
const { connection, reconnect } = useMarimoKernelConnection({
autoInstantiate: true,
setCells: setCells,
sessionId: getSessionId(),
Expand Down Expand Up @@ -77,6 +77,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
connection={connection}
isRunning={isRunning}
width={appConfig.width}
onReconnect={reconnect}
>
<AppHeader connection={connection} className="sm:pt-8">
{galleryHref && (
Expand Down
130 changes: 130 additions & 0 deletions frontend/src/core/websocket/__tests__/close-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import { classifyCloseEvent } from "../close-handler";
import { WebSocketClosedReason, WebSocketState } from "../types";

const MAX_RETRIES = 10;

function classify(
reason: string | undefined,
retryCount = 0,
maxRetries = MAX_RETRIES,
) {
return classifyCloseEvent({ reason, code: 1006 }, { retryCount, maxRetries });
}

describe("classifyCloseEvent", () => {
describe("transient closes (default branch)", () => {
it("retries when retryCount < maxRetries", () => {
const decision = classify(undefined, 0);
expect(decision.kind).toBe("retry");
expect(decision.status).toEqual({ state: WebSocketState.CONNECTING });
});

it("retries on each intermediate close event during a retry storm", () => {
for (let n = 0; n < MAX_RETRIES; n++) {
const decision = classify(undefined, n);
expect(decision.kind).toBe("retry");
expect(decision.status).toEqual({ state: WebSocketState.CONNECTING });
}
});

it("transitions to CLOSED when retryCount reaches maxRetries", () => {
const decision = classify(undefined, MAX_RETRIES);
expect(decision.kind).toBe("gave-up");
expect(decision.status).toEqual({
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
reason: "kernel not found",
});
});

it("transitions to CLOSED when retryCount exceeds maxRetries", () => {
const decision = classify(undefined, MAX_RETRIES + 5);
expect(decision.kind).toBe("gave-up");
});

it("treats unknown reason strings as transient", () => {
const decision = classify("something-else", 3);
expect(decision.kind).toBe("retry");
});
});

describe("terminal closes (server-initiated)", () => {
it("MARIMO_ALREADY_CONNECTED β†’ terminal + closeTransport, with takeover", () => {
const decision = classify("MARIMO_ALREADY_CONNECTED", 0);
expect(decision.kind).toBe("terminal");
expect(decision.status).toMatchObject({
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.ALREADY_RUNNING,
canTakeover: true,
});
if (decision.kind === "terminal") {
expect(decision.closeTransport).toBe(true);
}
});

it.each([
"MARIMO_WRONG_KERNEL_ID",
"MARIMO_NO_FILE_KEY",
"MARIMO_NO_SESSION_ID",
"MARIMO_NO_SESSION",
"MARIMO_SHUTDOWN",
])("%s β†’ terminal with KERNEL_DISCONNECTED, closes transport", (reason) => {
const decision = classify(reason, 0);
expect(decision.kind).toBe("terminal");
expect(decision.status).toMatchObject({
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
});
if (decision.kind === "terminal") {
expect(decision.closeTransport).toBe(true);
}
});

it("MARIMO_MALFORMED_QUERY β†’ terminal but does NOT close transport", () => {
const decision = classify("MARIMO_MALFORMED_QUERY", 0);
expect(decision.kind).toBe("terminal");
expect(decision.status).toMatchObject({
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.MALFORMED_QUERY,
});
if (decision.kind === "terminal") {
expect(decision.closeTransport).toBe(false);
}
});

it("MARIMO_KERNEL_STARTUP_ERROR β†’ terminal + closeTransport", () => {
const decision = classify("MARIMO_KERNEL_STARTUP_ERROR", 0);
expect(decision.kind).toBe("terminal");
expect(decision.status).toMatchObject({
state: WebSocketState.CLOSED,
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
});
if (decision.kind === "terminal") {
expect(decision.closeTransport).toBe(true);
}
});

it("terminal closes ignore retryCount entirely", () => {
const decision = classify("MARIMO_SHUTDOWN", 99);
expect(decision.kind).toBe("terminal");
});
});

describe("retry budget exhaustion", () => {
it("yields retry on attempts 1..maxRetries-1 and gave-up on the final close", () => {
const states: string[] = [];
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
states.push(classify(undefined, attempt - 1).kind);
}
states.push(classify(undefined, MAX_RETRIES).kind);

expect(states).toEqual([
...Array.from({ length: MAX_RETRIES }, () => "retry"),
"gave-up",
]);
});
});
});
Loading
Loading