@marimo-team/frontend 0.23.7-dev14 → 0.23.7-dev16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{cell-editor-DOImf428.js → cell-editor-BN3D1_w1.js} +1 -1
- package/dist/assets/{command-palette-CSa-ZC8t.js → command-palette-DuPUk7of.js} +1 -1
- package/dist/assets/{edit-page-oNoydBaC.js → edit-page-hJAbnd__.js} +6 -6
- package/dist/assets/{hooks-BpoZRNxw.js → hooks-DbPDczTe.js} +1 -1
- package/dist/assets/{index-5VlGukaI.js → index-DLZItUgg.js} +3 -3
- package/dist/assets/{layout-D5myrs5s.js → layout-C1dvirgi.js} +2 -2
- package/dist/assets/panels-Z5fVmDRY.js +1 -0
- package/dist/assets/{reveal-component-CJYRD8Yf.js → reveal-component-DLRiYfBj.js} +1 -1
- package/dist/assets/run-page-DBSf1bOm.js +1 -0
- package/dist/assets/{scratchpad-panel-SgUSM06x.js → scratchpad-panel-CUMSD7sH.js} +1 -1
- package/dist/assets/{state-BaHWS0qi.js → state-xEqF8Q3P.js} +1 -1
- package/dist/assets/{useNotebookActions-CrtC0jVZ.js → useNotebookActions-CZ7s2FNR.js} +1 -1
- package/dist/assets/ws-BV7dcs53.js +22 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/components/editor/app-container.tsx +7 -1
- package/src/components/editor/header/__tests__/status.test.tsx +108 -0
- package/src/components/editor/header/status.tsx +44 -10
- package/src/components/pages/run-page.tsx +4 -1
- package/src/core/edit-app.tsx +2 -1
- package/src/core/run-app.tsx +2 -1
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +155 -0
- package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +137 -0
- package/src/core/websocket/transports/basic.ts +2 -0
- package/src/core/websocket/transports/transport.ts +1 -0
- package/src/core/websocket/useMarimoKernelConnection.tsx +130 -55
- package/src/core/websocket/useWebSocket.tsx +5 -2
- package/dist/assets/panels-C-Vq2Qyd.js +0 -1
- package/dist/assets/run-page-C0hUYcpE.js +0 -1
- package/dist/assets/ws-BZQmQxZ7.js +0 -22
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
var p=Object.defineProperty;var y=(s,i,e)=>i in s?p(s,i,{enumerable:!0,configurable:!0,writable:!0,value:e}):s[i]=e;var o=(s,i,e)=>y(s,typeof i!="symbol"?i+"":i,e);var m;(!globalThis.EventTarget||!globalThis.Event)&&console.error(`
|
|
2
|
+
PartySocket requires a global 'EventTarget' class to be available!
|
|
3
|
+
You can polyfill this global by adding this to your code before any partysocket imports:
|
|
4
|
+
|
|
5
|
+
\`\`\`
|
|
6
|
+
import 'partysocket/event-target-polyfill';
|
|
7
|
+
\`\`\`
|
|
8
|
+
Please file an issue at https://github.com/partykit/partykit if you're still having trouble.
|
|
9
|
+
`);var _=class extends Event{constructor(i,e){super("error",e);o(this,"message");o(this,"error");this.message=i.message,this.error=i}},d=class extends Event{constructor(i=1e3,e="",t){super("close",t);o(this,"code");o(this,"reason");o(this,"wasClean",!0);this.code=i,this.reason=e}},u={Event,ErrorEvent:_,CloseEvent:d};function w(s,i){if(!s)throw Error(i)}function b(s){return new s.constructor(s.type,s)}function E(s){return"data"in s?new MessageEvent(s.type,s):"code"in s||"reason"in s?new d(s.code||1999,s.reason||"unknown reason",s):"error"in s?new _(s.error,s):new Event(s.type,s)}var v=typeof process<"u"&&((m=process.versions)==null?void 0:m.node)!==void 0&&typeof document>"u",f=typeof navigator<"u"&&navigator.product==="ReactNative",c=v||f?E:b,h={maxReconnectionDelay:1e4,minReconnectionDelay:1e3+Math.random()*4e3,minUptime:5e3,reconnectionDelayGrowFactor:1.3,connectionTimeout:4e3,maxRetries:1/0,maxEnqueuedMessages:1/0,startClosed:!1,debug:!1},g=!1,T=class a extends EventTarget{constructor(e,t,n={}){super();o(this,"_ws");o(this,"_retryCount",-1);o(this,"_uptimeTimeout");o(this,"_connectTimeout");o(this,"_shouldReconnect",!0);o(this,"_connectLock",!1);o(this,"_binaryType","blob");o(this,"_closeCalled",!1);o(this,"_messageQueue",[]);o(this,"_debugLogger",console.log.bind(console));o(this,"_url");o(this,"_protocols");o(this,"_options");o(this,"onclose",null);o(this,"onerror",null);o(this,"onmessage",null);o(this,"onopen",null);o(this,"_handleOpen",e=>{this._debug("open event");let{minUptime:t=h.minUptime}=this._options;clearTimeout(this._connectTimeout),this._uptimeTimeout=setTimeout(()=>this._acceptOpen(),t),w(this._ws,"WebSocket is not defined"),this._ws.binaryType=this._binaryType,this._messageQueue.forEach(n=>{var r;(r=this._ws)==null||r.send(n)}),this._messageQueue=[],this.onopen&&this.onopen(e),this.dispatchEvent(c(e))});o(this,"_handleMessage",e=>{this._debug("message event"),this.onmessage&&this.onmessage(e),this.dispatchEvent(c(e))});o(this,"_handleError",e=>{this._debug("error event",e.message),this._disconnect(void 0,e.message==="TIMEOUT"?"timeout":void 0),this.onerror&&this.onerror(e),this._debug("exec error listeners"),this.dispatchEvent(c(e)),this._connect()});o(this,"_handleClose",e=>{this._debug("close event"),this._clearTimeouts(),this._shouldReconnect&&this._connect(),this.onclose&&this.onclose(e),this.dispatchEvent(c(e))});this._url=e,this._protocols=t,this._options=n,this._options.startClosed&&(this._shouldReconnect=!1),this._options.debugLogger&&(this._debugLogger=this._options.debugLogger),this._connect()}static get CONNECTING(){return 0}static get OPEN(){return 1}static get CLOSING(){return 2}static get CLOSED(){return 3}get CONNECTING(){return a.CONNECTING}get OPEN(){return a.OPEN}get CLOSING(){return a.CLOSING}get CLOSED(){return a.CLOSED}get binaryType(){return this._ws?this._ws.binaryType:this._binaryType}set binaryType(e){this._binaryType=e,this._ws&&(this._ws.binaryType=e)}get retryCount(){return Math.max(this._retryCount,0)}get bufferedAmount(){return this._messageQueue.reduce((e,t)=>(typeof t=="string"?e+=t.length:t instanceof Blob?e+=t.size:e+=t.byteLength,e),0)+(this._ws?this._ws.bufferedAmount:0)}get extensions(){return this._ws?this._ws.extensions:""}get protocol(){return this._ws?this._ws.protocol:""}get readyState(){return this._ws?this._ws.readyState:this._options.startClosed?a.CLOSED:a.CONNECTING}get url(){return this._ws?this._ws.url:""}get shouldReconnect(){return this._shouldReconnect}close(e=1e3,t){if(this._closeCalled=!0,this._shouldReconnect=!1,this._clearTimeouts(),!this._ws){this._debug("close enqueued: no ws instance");return}if(this._ws.readyState===this.CLOSED){this._debug("close: already closed");return}this._ws.close(e,t)}reconnect(e,t){this._shouldReconnect=!0,this._closeCalled=!1,this._retryCount=-1,!this._ws||this._ws.readyState===this.CLOSED||this._disconnect(e,t),this._connect()}send(e){if(this._ws&&this._ws.readyState===this.OPEN)this._debug("send",e),this._ws.send(e);else{let{maxEnqueuedMessages:t=h.maxEnqueuedMessages}=this._options;this._messageQueue.length<t&&(this._debug("enqueue",e),this._messageQueue.push(e))}}_debug(...e){this._options.debug&&this._debugLogger("RWS>",...e)}_getNextDelay(){let{reconnectionDelayGrowFactor:e=h.reconnectionDelayGrowFactor,minReconnectionDelay:t=h.minReconnectionDelay,maxReconnectionDelay:n=h.maxReconnectionDelay}=this._options,r=0;return this._retryCount>0&&(r=t*e**(this._retryCount-1),r>n&&(r=n)),this._debug("next delay",r),r}_wait(){return new Promise(e=>{setTimeout(e,this._getNextDelay())})}_getNextProtocols(e){if(!e)return Promise.resolve(null);if(typeof e=="string"||Array.isArray(e))return Promise.resolve(e);if(typeof e=="function"){let t=e();if(!t)return Promise.resolve(null);if(typeof t=="string"||Array.isArray(t))return Promise.resolve(t);if(t.then)return t}throw Error("Invalid protocols")}_getNextUrl(e){if(typeof e=="string")return Promise.resolve(e);if(typeof e=="function"){let t=e();if(typeof t=="string")return Promise.resolve(t);if(t.then)return t}throw Error("Invalid URL")}_connect(){if(this._connectLock||!this._shouldReconnect)return;this._connectLock=!0;let{maxRetries:e=h.maxRetries,connectionTimeout:t=h.connectionTimeout}=this._options;if(this._retryCount>=e){this._debug("max retries reached",this._retryCount,">=",e),this._connectLock=!1;return}this._retryCount++,this._debug("connect",this._retryCount),this._removeListeners(),this._wait().then(()=>Promise.all([this._getNextUrl(this._url),this._getNextProtocols(this._protocols||null)])).then(([n,r])=>{if(this._closeCalled){this._connectLock=!1;return}!this._options.WebSocket&&typeof WebSocket>"u"&&!g&&(console.error(`\u203C\uFE0F No WebSocket implementation available. You should define options.WebSocket.
|
|
10
|
+
|
|
11
|
+
For example, if you're using node.js, run \`npm install ws\`, and then in your code:
|
|
12
|
+
|
|
13
|
+
import PartySocket from 'partysocket';
|
|
14
|
+
import WS from 'ws';
|
|
15
|
+
|
|
16
|
+
const partysocket = new PartySocket({
|
|
17
|
+
host: "127.0.0.1:1999",
|
|
18
|
+
room: "test-room",
|
|
19
|
+
WebSocket: WS
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
`),g=!0);let l=this._options.WebSocket||WebSocket;this._debug("connect",{url:n,protocols:r}),this._ws=r?new l(n,r):new l(n),this._ws.binaryType=this._binaryType,this._connectLock=!1,this._addListeners(),this._connectTimeout=setTimeout(()=>this._handleTimeout(),t)}).catch(n=>{this._connectLock=!1,this._handleError(new u.ErrorEvent(Error(n.message),this))})}_handleTimeout(){this._debug("timeout event"),this._handleError(new u.ErrorEvent(Error("TIMEOUT"),this))}_disconnect(e=1e3,t){if(this._clearTimeouts(),this._ws){this._removeListeners();try{(this._ws.readyState===this.OPEN||this._ws.readyState===this.CONNECTING)&&this._ws.close(e,t),this._handleClose(new u.CloseEvent(e,t,this))}catch{}}}_acceptOpen(){this._debug("accept open"),this._retryCount=0}_removeListeners(){this._ws&&(this._debug("removeListeners"),this._ws.removeEventListener("open",this._handleOpen),this._ws.removeEventListener("close",this._handleClose),this._ws.removeEventListener("message",this._handleMessage),this._ws.removeEventListener("error",this._handleError))}_addListeners(){this._ws&&(this._debug("addListeners"),this._ws.addEventListener("open",this._handleOpen),this._ws.addEventListener("close",this._handleClose),this._ws.addEventListener("message",this._handleMessage),this._ws.addEventListener("error",this._handleError))}_clearTimeouts(){clearTimeout(this._connectTimeout),clearTimeout(this._uptimeTimeout)}};export{T as t};
|
package/dist/index.html
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
<marimo-server-token data-token="{{ server_token }}" hidden></marimo-server-token>
|
|
67
67
|
<!-- /TODO -->
|
|
68
68
|
<title>{{ title }}</title>
|
|
69
|
-
<script type="module" crossorigin src="./assets/index-
|
|
69
|
+
<script type="module" crossorigin src="./assets/index-DLZItUgg.js"></script>
|
|
70
70
|
<link rel="modulepreload" crossorigin href="./assets/preload-helper-DItdS47A.js">
|
|
71
71
|
<link rel="modulepreload" crossorigin href="./assets/chunk-LvLJmgfZ.js">
|
|
72
72
|
<link rel="modulepreload" crossorigin href="./assets/react-Bj1aDYRI.js">
|
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
<link rel="modulepreload" crossorigin href="./assets/memoize-Tp7rARFe.js">
|
|
221
221
|
<link rel="modulepreload" crossorigin href="./assets/get-C-qh_et5.js">
|
|
222
222
|
<link rel="modulepreload" crossorigin href="./assets/_baseSet-CxV9N1bc.js">
|
|
223
|
-
<link rel="modulepreload" crossorigin href="./assets/state-
|
|
223
|
+
<link rel="modulepreload" crossorigin href="./assets/state-xEqF8Q3P.js">
|
|
224
224
|
<link rel="modulepreload" crossorigin href="./assets/label-DTR8T0AE.js">
|
|
225
225
|
<link rel="modulepreload" crossorigin href="./assets/textarea-bAp21zYj.js">
|
|
226
226
|
<link rel="modulepreload" crossorigin href="./assets/refresh-ccw-C-n2VFP5.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marimo-team/frontend",
|
|
3
|
-
"version": "0.23.7-
|
|
3
|
+
"version": "0.23.7-dev16",
|
|
4
4
|
"main": "dist/main.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
"lz-string": "^1.5.0",
|
|
121
121
|
"marked": "^15.0.12",
|
|
122
122
|
"mermaid": "^11.12.3",
|
|
123
|
-
"partysocket": "1.1.
|
|
123
|
+
"partysocket": "1.1.13",
|
|
124
124
|
"path-to-regexp": "^8.4.0",
|
|
125
125
|
"plotly.js": "^3.3.1",
|
|
126
126
|
"pyodide": "0.27.7",
|
|
@@ -15,6 +15,7 @@ interface Props {
|
|
|
15
15
|
connection: ConnectionStatus;
|
|
16
16
|
isRunning: boolean;
|
|
17
17
|
width: AppConfig["width"];
|
|
18
|
+
onReconnect?: () => void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const AppContainer: React.FC<PropsWithChildren<Props>> = ({
|
|
@@ -22,13 +23,18 @@ export const AppContainer: React.FC<PropsWithChildren<Props>> = ({
|
|
|
22
23
|
connection,
|
|
23
24
|
isRunning,
|
|
24
25
|
children,
|
|
26
|
+
onReconnect,
|
|
25
27
|
}) => {
|
|
26
28
|
const connectionState = connection.state;
|
|
27
29
|
|
|
28
30
|
return (
|
|
29
31
|
<>
|
|
30
32
|
<DynamicFavicon isRunning={isRunning} />
|
|
31
|
-
<StatusOverlay
|
|
33
|
+
<StatusOverlay
|
|
34
|
+
connection={connection}
|
|
35
|
+
isRunning={isRunning}
|
|
36
|
+
onReconnect={onReconnect}
|
|
37
|
+
/>
|
|
32
38
|
<PyodideLoader>
|
|
33
39
|
<WrappedWithSidebar>
|
|
34
40
|
{/** oxlint-ignore-next-line -- ID is used by other components to grab the DOM element */}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
|
|
4
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
5
|
+
import { createStore, Provider as JotaiProvider } from "jotai";
|
|
6
|
+
import type React from "react";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
9
|
+
import { viewStateAtom } from "@/core/mode";
|
|
10
|
+
import {
|
|
11
|
+
type ConnectionStatus,
|
|
12
|
+
WebSocketClosedReason,
|
|
13
|
+
WebSocketState,
|
|
14
|
+
} from "@/core/websocket/types";
|
|
15
|
+
import { StatusOverlay } from "../status";
|
|
16
|
+
|
|
17
|
+
function renderOverlay(
|
|
18
|
+
connection: ConnectionStatus,
|
|
19
|
+
onReconnect?: () => void,
|
|
20
|
+
): ReturnType<typeof render> {
|
|
21
|
+
const store = createStore();
|
|
22
|
+
store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
|
|
23
|
+
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
|
|
24
|
+
<JotaiProvider store={store}>
|
|
25
|
+
<TooltipProvider>{children}</TooltipProvider>
|
|
26
|
+
</JotaiProvider>
|
|
27
|
+
);
|
|
28
|
+
return render(
|
|
29
|
+
<StatusOverlay
|
|
30
|
+
connection={connection}
|
|
31
|
+
isRunning={false}
|
|
32
|
+
onReconnect={onReconnect}
|
|
33
|
+
/>,
|
|
34
|
+
{ wrapper },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("StatusOverlay disconnect indicator", () => {
|
|
39
|
+
it("invokes onReconnect when the disconnect icon is clicked", () => {
|
|
40
|
+
const onReconnect = vi.fn();
|
|
41
|
+
const { getByTestId } = renderOverlay(
|
|
42
|
+
{
|
|
43
|
+
state: WebSocketState.CLOSED,
|
|
44
|
+
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
45
|
+
reason: "kernel not found",
|
|
46
|
+
},
|
|
47
|
+
onReconnect,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const icon = getByTestId("disconnected-indicator") as HTMLButtonElement;
|
|
51
|
+
expect(icon.tagName).toBe("BUTTON");
|
|
52
|
+
expect(icon.disabled).toBe(false);
|
|
53
|
+
expect(icon.getAttribute("aria-label")).toBe("Reconnect to app");
|
|
54
|
+
fireEvent.click(icon);
|
|
55
|
+
expect(onReconnect).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders a disabled button when no onReconnect is provided", () => {
|
|
59
|
+
const { getByTestId } = renderOverlay({
|
|
60
|
+
state: WebSocketState.CLOSED,
|
|
61
|
+
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
62
|
+
reason: "kernel not found",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const button = getByTestId("disconnected-indicator");
|
|
66
|
+
expect((button as HTMLButtonElement).disabled).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it.each([
|
|
70
|
+
[
|
|
71
|
+
WebSocketClosedReason.MALFORMED_QUERY,
|
|
72
|
+
"the kernel did not recognize a request; please file a bug with marimo",
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
76
|
+
"Failed to start kernel sandbox",
|
|
77
|
+
],
|
|
78
|
+
])(
|
|
79
|
+
"renders a disabled button for non-recoverable close reason %s",
|
|
80
|
+
(code, reason) => {
|
|
81
|
+
const onReconnect = vi.fn();
|
|
82
|
+
const { getByTestId } = renderOverlay(
|
|
83
|
+
{ state: WebSocketState.CLOSED, code, reason },
|
|
84
|
+
onReconnect,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const button = getByTestId("disconnected-indicator") as HTMLButtonElement;
|
|
88
|
+
expect(button.disabled).toBe(true);
|
|
89
|
+
fireEvent.click(button);
|
|
90
|
+
expect(onReconnect).not.toHaveBeenCalled();
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
it("does not render the disconnect icon when another tab has taken over", () => {
|
|
95
|
+
const onReconnect = vi.fn();
|
|
96
|
+
const { queryByTestId } = renderOverlay(
|
|
97
|
+
{
|
|
98
|
+
state: WebSocketState.CLOSED,
|
|
99
|
+
code: WebSocketClosedReason.ALREADY_RUNNING,
|
|
100
|
+
reason: "another browser tab is already connected to the kernel",
|
|
101
|
+
canTakeover: true,
|
|
102
|
+
},
|
|
103
|
+
onReconnect,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(queryByTestId("disconnected-indicator")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -7,16 +7,26 @@ import { Tooltip } from "@/components/ui/tooltip";
|
|
|
7
7
|
import { notebookScrollToRunning } from "@/core/cells/actions";
|
|
8
8
|
import { onlyScratchpadIsRunningAtom } from "@/core/cells/cells";
|
|
9
9
|
import { viewStateAtom } from "@/core/mode";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type ConnectionStatus,
|
|
12
|
+
WebSocketClosedReason,
|
|
13
|
+
WebSocketState,
|
|
14
|
+
} from "@/core/websocket/types";
|
|
11
15
|
import { cn } from "@/utils/cn";
|
|
12
16
|
|
|
13
17
|
export const StatusOverlay: React.FC<{
|
|
14
18
|
connection: ConnectionStatus;
|
|
15
19
|
isRunning: boolean;
|
|
16
|
-
|
|
20
|
+
onReconnect?: () => void;
|
|
21
|
+
}> = ({ connection, isRunning, onReconnect }) => {
|
|
17
22
|
const { mode } = useAtomValue(viewStateAtom);
|
|
18
23
|
const isClosed = connection.state === WebSocketState.CLOSED;
|
|
19
24
|
const isOpen = connection.state === WebSocketState.OPEN;
|
|
25
|
+
// Only KERNEL_DISCONNECTED is recoverable by a retry. Other terminal
|
|
26
|
+
// reasons (MALFORMED_QUERY, KERNEL_STARTUP_ERROR) would deterministically
|
|
27
|
+
// fail the same way; ALREADY_RUNNING is handled by `LockedIcon` below.
|
|
28
|
+
const canReconnect =
|
|
29
|
+
isClosed && connection.code === WebSocketClosedReason.KERNEL_DISCONNECTED;
|
|
20
30
|
|
|
21
31
|
return (
|
|
22
32
|
<>
|
|
@@ -28,7 +38,11 @@ export const StatusOverlay: React.FC<{
|
|
|
28
38
|
)}
|
|
29
39
|
>
|
|
30
40
|
{isOpen && isRunning && <RunningIcon />}
|
|
31
|
-
{isClosed && !connection.canTakeover &&
|
|
41
|
+
{isClosed && !connection.canTakeover && (
|
|
42
|
+
<DisconnectedIcon
|
|
43
|
+
onReconnect={canReconnect ? onReconnect : undefined}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
32
46
|
{isClosed && connection.canTakeover && <LockedIcon />}
|
|
33
47
|
</div>
|
|
34
48
|
</>
|
|
@@ -37,13 +51,33 @@ export const StatusOverlay: React.FC<{
|
|
|
37
51
|
|
|
38
52
|
const topLeftStatus = "print:hidden pointer-events-auto hover:cursor-pointer";
|
|
39
53
|
|
|
40
|
-
const DisconnectedIcon
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
const DisconnectedIcon: React.FC<{ onReconnect?: () => void }> = ({
|
|
55
|
+
onReconnect,
|
|
56
|
+
}) => {
|
|
57
|
+
const disabled = !onReconnect;
|
|
58
|
+
return (
|
|
59
|
+
<Tooltip
|
|
60
|
+
content={
|
|
61
|
+
disabled ? "App disconnected" : "App disconnected — click to reconnect"
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
{/* Wrapper span keeps the tooltip reachable when the button is
|
|
65
|
+
disabled — a disabled <button> swallows pointer events. */}
|
|
66
|
+
<span tabIndex={disabled ? 0 : -1}>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
className={cn(topLeftStatus, "bg-transparent border-0 p-0")}
|
|
70
|
+
aria-label={disabled ? "App disconnected" : "Reconnect to app"}
|
|
71
|
+
data-testid="disconnected-indicator"
|
|
72
|
+
onClick={onReconnect}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
>
|
|
75
|
+
<UnlinkIcon className="w-[25px] h-[25px] text-(--red-11)" />
|
|
76
|
+
</button>
|
|
77
|
+
</span>
|
|
78
|
+
</Tooltip>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
47
81
|
|
|
48
82
|
const LockedIcon = () => (
|
|
49
83
|
<Tooltip content="Notebook locked">
|
|
@@ -34,7 +34,10 @@ const RunPage = (props: Props) => {
|
|
|
34
34
|
|
|
35
35
|
const Watermark = () => {
|
|
36
36
|
return (
|
|
37
|
-
<div
|
|
37
|
+
<div
|
|
38
|
+
className="fixed bottom-0 right-0 z-50 print:hidden"
|
|
39
|
+
data-testid="watermark"
|
|
40
|
+
>
|
|
38
41
|
<a
|
|
39
42
|
href={Constants.githubPage}
|
|
40
43
|
target="_blank"
|
package/src/core/edit-app.tsx
CHANGED
|
@@ -79,7 +79,7 @@ export const EditApp: React.FC<AppProps> = ({
|
|
|
79
79
|
};
|
|
80
80
|
}, []);
|
|
81
81
|
|
|
82
|
-
const { connection } = useMarimoKernelConnection({
|
|
82
|
+
const { connection, reconnect } = useMarimoKernelConnection({
|
|
83
83
|
autoInstantiate: userConfig.runtime.auto_instantiate,
|
|
84
84
|
setCells: (cells, layout) => {
|
|
85
85
|
setCells(cells);
|
|
@@ -147,6 +147,7 @@ export const EditApp: React.FC<AppProps> = ({
|
|
|
147
147
|
connection={connection}
|
|
148
148
|
isRunning={isRunning}
|
|
149
149
|
width={appConfig.width}
|
|
150
|
+
onReconnect={reconnect}
|
|
150
151
|
>
|
|
151
152
|
<AppHeader
|
|
152
153
|
connection={connection}
|
package/src/core/run-app.tsx
CHANGED
|
@@ -38,7 +38,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
|
|
|
38
38
|
};
|
|
39
39
|
}, []);
|
|
40
40
|
|
|
41
|
-
const { connection } = useMarimoKernelConnection({
|
|
41
|
+
const { connection, reconnect } = useMarimoKernelConnection({
|
|
42
42
|
autoInstantiate: true,
|
|
43
43
|
setCells: setCells,
|
|
44
44
|
sessionId: getSessionId(),
|
|
@@ -84,6 +84,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
|
|
|
84
84
|
connection={connection}
|
|
85
85
|
isRunning={isRunning}
|
|
86
86
|
width={appConfig.width}
|
|
87
|
+
onReconnect={reconnect}
|
|
87
88
|
>
|
|
88
89
|
<AppHeader connection={connection} className="sm:pt-8">
|
|
89
90
|
{galleryHref && (
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
|
|
4
|
+
import { act, renderHook } from "@testing-library/react";
|
|
5
|
+
import { createStore, Provider as JotaiProvider } from "jotai";
|
|
6
|
+
import type React from "react";
|
|
7
|
+
import { ErrorBoundary } from "react-error-boundary";
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
vi.mock("@/core/websocket/useWebSocket", async () => {
|
|
11
|
+
const actual =
|
|
12
|
+
await vi.importActual<typeof import("../useWebSocket")>("../useWebSocket");
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
useConnectionTransport: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
vi.mock("@/core/runtime/config", async () => {
|
|
20
|
+
const actual = await vi.importActual<typeof import("@/core/runtime/config")>(
|
|
21
|
+
"@/core/runtime/config",
|
|
22
|
+
);
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
useRuntimeManager: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
import { useRuntimeManager } from "@/core/runtime/config";
|
|
30
|
+
import { connectionAtom } from "../../network/connection";
|
|
31
|
+
import type { SessionId } from "../../kernel/session";
|
|
32
|
+
import { WebSocketClosedReason, WebSocketState } from "../types";
|
|
33
|
+
import { useMarimoKernelConnection } from "../useMarimoKernelConnection";
|
|
34
|
+
import { useConnectionTransport } from "../useWebSocket";
|
|
35
|
+
|
|
36
|
+
interface MockTransport {
|
|
37
|
+
readyState: 0 | 1 | 2 | 3;
|
|
38
|
+
retryCount: number;
|
|
39
|
+
reconnect: ReturnType<typeof vi.fn>;
|
|
40
|
+
close: ReturnType<typeof vi.fn>;
|
|
41
|
+
send: ReturnType<typeof vi.fn>;
|
|
42
|
+
addEventListener: ReturnType<typeof vi.fn>;
|
|
43
|
+
removeEventListener: ReturnType<typeof vi.fn>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeTransport(
|
|
47
|
+
readyState: 0 | 1 | 2 | 3 = WebSocket.CLOSED,
|
|
48
|
+
): MockTransport {
|
|
49
|
+
return {
|
|
50
|
+
readyState,
|
|
51
|
+
retryCount: 0,
|
|
52
|
+
reconnect: vi.fn(),
|
|
53
|
+
close: vi.fn(),
|
|
54
|
+
send: vi.fn(),
|
|
55
|
+
addEventListener: vi.fn(),
|
|
56
|
+
removeEventListener: vi.fn(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeRuntimeManager(isHealthy = vi.fn().mockResolvedValue(true)) {
|
|
61
|
+
return {
|
|
62
|
+
isHealthy,
|
|
63
|
+
getWsURL: () => new URL("ws://localhost/ws"),
|
|
64
|
+
waitForHealthy: vi.fn().mockResolvedValue(undefined),
|
|
65
|
+
isSameOrigin: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("useMarimoKernelConnection.reconnect()", () => {
|
|
70
|
+
let transport: MockTransport;
|
|
71
|
+
let isHealthy: ReturnType<typeof vi.fn>;
|
|
72
|
+
let store: ReturnType<typeof createStore>;
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
transport = makeTransport(WebSocket.CLOSED);
|
|
76
|
+
isHealthy = vi.fn().mockResolvedValue(true);
|
|
77
|
+
store = createStore();
|
|
78
|
+
store.set(connectionAtom, {
|
|
79
|
+
state: WebSocketState.CLOSED,
|
|
80
|
+
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
81
|
+
reason: "kernel not found",
|
|
82
|
+
});
|
|
83
|
+
vi.mocked(useConnectionTransport).mockReturnValue(transport);
|
|
84
|
+
vi.mocked(useRuntimeManager).mockReturnValue(
|
|
85
|
+
makeRuntimeManager(isHealthy) as unknown as ReturnType<
|
|
86
|
+
typeof useRuntimeManager
|
|
87
|
+
>,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function renderUseHook() {
|
|
92
|
+
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
|
|
93
|
+
<JotaiProvider store={store}>
|
|
94
|
+
<ErrorBoundary fallback={null}>{children}</ErrorBoundary>
|
|
95
|
+
</JotaiProvider>
|
|
96
|
+
);
|
|
97
|
+
return renderHook(
|
|
98
|
+
() =>
|
|
99
|
+
useMarimoKernelConnection({
|
|
100
|
+
sessionId: "test-session" as SessionId,
|
|
101
|
+
autoInstantiate: false,
|
|
102
|
+
setCells: () => {},
|
|
103
|
+
}),
|
|
104
|
+
{ wrapper },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it("is a no-op when the transport is already OPEN", async () => {
|
|
109
|
+
transport.readyState = WebSocket.OPEN;
|
|
110
|
+
const { result } = renderUseHook();
|
|
111
|
+
await act(async () => {
|
|
112
|
+
await result.current.reconnect();
|
|
113
|
+
});
|
|
114
|
+
expect(isHealthy).not.toHaveBeenCalled();
|
|
115
|
+
expect(transport.reconnect).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is a no-op when the transport is already CONNECTING", async () => {
|
|
119
|
+
transport.readyState = WebSocket.CONNECTING;
|
|
120
|
+
const { result } = renderUseHook();
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.reconnect();
|
|
123
|
+
});
|
|
124
|
+
expect(isHealthy).not.toHaveBeenCalled();
|
|
125
|
+
expect(transport.reconnect).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("probes /health and reconnects when the runtime is healthy", async () => {
|
|
129
|
+
isHealthy.mockResolvedValue(true);
|
|
130
|
+
const { result } = renderUseHook();
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await result.current.reconnect();
|
|
133
|
+
});
|
|
134
|
+
expect(isHealthy).toHaveBeenCalledOnce();
|
|
135
|
+
expect(transport.reconnect).toHaveBeenCalledOnce();
|
|
136
|
+
expect(store.get(connectionAtom)).toEqual({
|
|
137
|
+
state: WebSocketState.CONNECTING,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("transitions to CLOSED and does not call ws.reconnect when the probe fails", async () => {
|
|
142
|
+
isHealthy.mockResolvedValue(false);
|
|
143
|
+
const { result } = renderUseHook();
|
|
144
|
+
await act(async () => {
|
|
145
|
+
await result.current.reconnect();
|
|
146
|
+
});
|
|
147
|
+
expect(isHealthy).toHaveBeenCalledOnce();
|
|
148
|
+
expect(transport.reconnect).not.toHaveBeenCalled();
|
|
149
|
+
expect(store.get(connectionAtom)).toEqual({
|
|
150
|
+
state: WebSocketState.CLOSED,
|
|
151
|
+
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
152
|
+
reason: "kernel not found",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { Logger } from "@/utils/Logger";
|
|
5
|
+
import { WebSocketClosedReason, WebSocketState } from "../types";
|
|
6
|
+
import { classifyCloseEvent } from "../useMarimoKernelConnection";
|
|
7
|
+
import { MAX_RETRIES } from "../useWebSocket";
|
|
8
|
+
|
|
9
|
+
function classify(
|
|
10
|
+
reason: string | undefined,
|
|
11
|
+
retryCount = 0,
|
|
12
|
+
maxRetries = MAX_RETRIES,
|
|
13
|
+
) {
|
|
14
|
+
return classifyCloseEvent({ reason }, { retryCount, maxRetries });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("classifyCloseEvent", () => {
|
|
18
|
+
describe("transient closes (default branch)", () => {
|
|
19
|
+
it("retries when retryCount < maxRetries", () => {
|
|
20
|
+
const decision = classify(undefined, 0);
|
|
21
|
+
expect(decision.kind).toBe("retry");
|
|
22
|
+
expect(decision.status).toEqual({ state: WebSocketState.CONNECTING });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("retries on each intermediate close event during a retry storm", () => {
|
|
26
|
+
for (let n = 0; n < MAX_RETRIES; n++) {
|
|
27
|
+
const decision = classify(undefined, n);
|
|
28
|
+
expect(decision.kind).toBe("retry");
|
|
29
|
+
expect(decision.status).toEqual({ state: WebSocketState.CONNECTING });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("transitions to CLOSED when retryCount reaches maxRetries", () => {
|
|
34
|
+
const decision = classify(undefined, MAX_RETRIES);
|
|
35
|
+
expect(decision.kind).toBe("gave-up");
|
|
36
|
+
expect(decision.status).toEqual({
|
|
37
|
+
state: WebSocketState.CLOSED,
|
|
38
|
+
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
39
|
+
reason: "kernel not found",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("transitions to CLOSED when retryCount exceeds maxRetries", () => {
|
|
44
|
+
const decision = classify(undefined, MAX_RETRIES + 5);
|
|
45
|
+
expect(decision.kind).toBe("gave-up");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("treats unknown reason strings as transient and logs a warning", () => {
|
|
49
|
+
const logger = vi.spyOn(Logger, "warn").mockImplementation(() => {});
|
|
50
|
+
const decision = classify("something-else", 3);
|
|
51
|
+
expect(decision.kind).toBe("retry");
|
|
52
|
+
expect(logger).toHaveBeenCalled();
|
|
53
|
+
logger.mockRestore();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("terminal closes (server-initiated)", () => {
|
|
62
|
+
it("MARIMO_ALREADY_CONNECTED → terminal + closeTransport, with takeover", () => {
|
|
63
|
+
const decision = classify("MARIMO_ALREADY_CONNECTED", 0);
|
|
64
|
+
expect(decision.kind).toBe("terminal");
|
|
65
|
+
expect(decision.status).toMatchObject({
|
|
66
|
+
state: WebSocketState.CLOSED,
|
|
67
|
+
code: WebSocketClosedReason.ALREADY_RUNNING,
|
|
68
|
+
canTakeover: true,
|
|
69
|
+
});
|
|
70
|
+
if (decision.kind === "terminal") {
|
|
71
|
+
expect(decision.closeTransport).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each([
|
|
76
|
+
"MARIMO_WRONG_KERNEL_ID",
|
|
77
|
+
"MARIMO_NO_FILE_KEY",
|
|
78
|
+
"MARIMO_NO_SESSION_ID",
|
|
79
|
+
"MARIMO_NO_SESSION",
|
|
80
|
+
"MARIMO_SHUTDOWN",
|
|
81
|
+
])("%s → terminal with KERNEL_DISCONNECTED, closes transport", (reason) => {
|
|
82
|
+
const decision = classify(reason, 0);
|
|
83
|
+
expect(decision.kind).toBe("terminal");
|
|
84
|
+
expect(decision.status).toMatchObject({
|
|
85
|
+
state: WebSocketState.CLOSED,
|
|
86
|
+
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
87
|
+
});
|
|
88
|
+
if (decision.kind === "terminal") {
|
|
89
|
+
expect(decision.closeTransport).toBe(true);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("MARIMO_MALFORMED_QUERY → terminal but does NOT close transport", () => {
|
|
94
|
+
const decision = classify("MARIMO_MALFORMED_QUERY", 0);
|
|
95
|
+
expect(decision.kind).toBe("terminal");
|
|
96
|
+
expect(decision.status).toMatchObject({
|
|
97
|
+
state: WebSocketState.CLOSED,
|
|
98
|
+
code: WebSocketClosedReason.MALFORMED_QUERY,
|
|
99
|
+
});
|
|
100
|
+
if (decision.kind === "terminal") {
|
|
101
|
+
expect(decision.closeTransport).toBe(false);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("MARIMO_KERNEL_STARTUP_ERROR → terminal + closeTransport", () => {
|
|
106
|
+
const decision = classify("MARIMO_KERNEL_STARTUP_ERROR", 0);
|
|
107
|
+
expect(decision.kind).toBe("terminal");
|
|
108
|
+
expect(decision.status).toMatchObject({
|
|
109
|
+
state: WebSocketState.CLOSED,
|
|
110
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
111
|
+
});
|
|
112
|
+
if (decision.kind === "terminal") {
|
|
113
|
+
expect(decision.closeTransport).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("terminal closes ignore retryCount entirely", () => {
|
|
118
|
+
const decision = classify("MARIMO_SHUTDOWN", 99);
|
|
119
|
+
expect(decision.kind).toBe("terminal");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("retry budget exhaustion", () => {
|
|
124
|
+
it("yields retry on attempts 1..maxRetries-1 and gave-up on the final close", () => {
|
|
125
|
+
const states: string[] = [];
|
|
126
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
127
|
+
states.push(classify(undefined, attempt - 1).kind);
|
|
128
|
+
}
|
|
129
|
+
states.push(classify(undefined, MAX_RETRIES).kind);
|
|
130
|
+
|
|
131
|
+
expect(states).toEqual([
|
|
132
|
+
...Array.from({ length: MAX_RETRIES }, () => "retry"),
|
|
133
|
+
"gave-up",
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|