@marimo-team/islands 0.25.3-dev139 → 0.25.3-dev140
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/{ErrorBoundary-BX9mTNLJ.js → ErrorBoundary-CTJ1U1fx.js} +1 -1
- package/dist/assets/__vite-browser-external-BgQNn4dS.js +1 -0
- package/dist/assets/worker-FKe3RHT4.js +113 -0
- package/dist/{code-visibility-gptWgGHp.js → code-visibility-C1jZfwPW.js} +1 -1
- package/dist/{common-mU-2k6Zp.js → common-CNv_7gvI.js} +1 -1
- package/dist/{data-grid-overlay-editor-Bdyx_BTg.js → data-grid-overlay-editor-t0t17DPI.js} +1 -1
- package/dist/{feedback-button-BoXM4uir.js → feedback-button-JzaiyZZm.js} +2 -2
- package/dist/{glide-data-editor-DaXYMyzk.js → glide-data-editor-B3eq-1SC.js} +3 -3
- package/dist/{globals-Bj4btPVp.js → globals-BW8TOLYQ.js} +1 -1
- package/dist/main.js +7 -7
- package/dist/{number-overlay-editor-DHfzwLIx.js → number-overlay-editor-DoUnDbPE.js} +1 -1
- package/dist/{reveal-component-CurCj3ks.js → reveal-component-ClNn1CDl.js} +2 -2
- package/package.json +1 -1
- package/src/core/host/__tests__/wasm.test.ts +1 -0
- package/src/core/host/wasm.ts +10 -5
- package/src/core/islands/worker/__tests__/controller.test.ts +62 -3
- package/src/core/islands/worker/controller.ts +4 -6
- package/src/core/islands/worker/worker.tsx +11 -11
- package/src/core/wasm/__tests__/bridge.test.ts +42 -0
- package/src/core/wasm/__tests__/worker.test.ts +182 -0
- package/src/core/wasm/bridge.ts +7 -3
- package/src/core/wasm/worker/bootstrap.ts +52 -35
- package/src/core/wasm/worker/types.ts +11 -8
- package/src/core/wasm/worker/worker.ts +31 -14
- package/dist/assets/__vite-browser-external-DrmlAsRP.js +0 -1
- package/dist/assets/worker-O2XUgidQ.js +0 -113
package/src/core/host/wasm.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface WasmNotebookBridge {
|
|
|
15
15
|
subscribe(
|
|
16
16
|
cursor: string | null,
|
|
17
17
|
emit: (event: unknown) => void,
|
|
18
|
+
signal: AbortSignal,
|
|
18
19
|
): Promise<() => void>;
|
|
19
20
|
/** Deliver a call to the worker and resolve with its status and body. */
|
|
20
21
|
call(call: unstable_NotebookCall): Promise<unknown>;
|
|
@@ -65,11 +66,15 @@ export function createWasmNotebookBackend(
|
|
|
65
66
|
await abortable(
|
|
66
67
|
() =>
|
|
67
68
|
bridge
|
|
68
|
-
.subscribe(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
.subscribe(
|
|
70
|
+
cursor,
|
|
71
|
+
(event) => {
|
|
72
|
+
if (!signal.aborted) {
|
|
73
|
+
message(event);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
signal,
|
|
77
|
+
)
|
|
73
78
|
.then((close) => {
|
|
74
79
|
unsubscribe = close;
|
|
75
80
|
if (signal.aborted) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { PyodideInterface } from "pyodide";
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Deferred } from "@/utils/Deferred";
|
|
5
6
|
import { ReadonlyWasmController } from "../controller";
|
|
6
7
|
|
|
7
8
|
class TestController extends ReadonlyWasmController {
|
|
@@ -50,14 +51,71 @@ function createPyodideStub(
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
function
|
|
54
|
-
return controller.
|
|
54
|
+
function prepareSession(controller: TestController, code: string) {
|
|
55
|
+
return controller.prepareSession({
|
|
55
56
|
code,
|
|
56
57
|
filename: `${code}.py`,
|
|
57
58
|
});
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
async function startSession(controller: TestController, code: string) {
|
|
62
|
+
const session = await prepareSession(controller, code);
|
|
63
|
+
void session.start();
|
|
64
|
+
return session;
|
|
65
|
+
}
|
|
66
|
+
|
|
60
67
|
describe("WASM controller session lifecycle", () => {
|
|
68
|
+
it("releases a prepared session and ignores start after stop", async () => {
|
|
69
|
+
const { init, pyodide } = createPyodideStub();
|
|
70
|
+
const controller = new TestController();
|
|
71
|
+
controller.setPyodide(pyodide);
|
|
72
|
+
|
|
73
|
+
const session = await prepareSession(controller, "current");
|
|
74
|
+
await session.stop();
|
|
75
|
+
expect(init.destroy).toHaveBeenCalledOnce();
|
|
76
|
+
await session.start();
|
|
77
|
+
expect(init).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it.each(["resolve", "reject"] as const)(
|
|
81
|
+
"releases a stopped session before its dependencies %s",
|
|
82
|
+
async (outcome) => {
|
|
83
|
+
const dependencies = new Deferred<void>();
|
|
84
|
+
const { init, loadPackagesFromImports, pyodide, bridge, stop } =
|
|
85
|
+
createPyodideStub();
|
|
86
|
+
loadPackagesFromImports.mockReturnValue(dependencies.promise);
|
|
87
|
+
const controller = new TestController();
|
|
88
|
+
controller.setPyodide(pyodide);
|
|
89
|
+
const session = await prepareSession(controller, "current");
|
|
90
|
+
const starting = session.start();
|
|
91
|
+
expect(init).not.toHaveBeenCalled();
|
|
92
|
+
await session.stop();
|
|
93
|
+
expect(init.destroy).toHaveBeenCalledOnce();
|
|
94
|
+
expect(bridge.destroy).toHaveBeenCalledOnce();
|
|
95
|
+
if (outcome === "resolve") {
|
|
96
|
+
dependencies.resolve();
|
|
97
|
+
} else {
|
|
98
|
+
dependencies.reject(new Error("Loading failed after stop"));
|
|
99
|
+
}
|
|
100
|
+
await starting;
|
|
101
|
+
await session.stop();
|
|
102
|
+
expect(stop).toHaveBeenCalledOnce();
|
|
103
|
+
expect(init).not.toHaveBeenCalled();
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
it("reports dependency failures when starting an active session", async () => {
|
|
108
|
+
const { init, loadPackagesFromImports, pyodide } = createPyodideStub();
|
|
109
|
+
loadPackagesFromImports.mockRejectedValue(new Error("Dependency failed"));
|
|
110
|
+
const controller = new TestController();
|
|
111
|
+
controller.setPyodide(pyodide);
|
|
112
|
+
const session = await prepareSession(controller, "current");
|
|
113
|
+
await expect(session.start()).rejects.toThrow("Dependency failed");
|
|
114
|
+
expect(init).not.toHaveBeenCalled();
|
|
115
|
+
expect(init.destroy).toHaveBeenCalledOnce();
|
|
116
|
+
await session.stop();
|
|
117
|
+
});
|
|
118
|
+
|
|
61
119
|
it("stops and releases the active session", async () => {
|
|
62
120
|
const { init, packages, pyodide, sessionResources, stop } =
|
|
63
121
|
createPyodideStub();
|
|
@@ -115,7 +173,6 @@ describe("WASM controller session lifecycle", () => {
|
|
|
115
173
|
|
|
116
174
|
await startSession(controller, "first");
|
|
117
175
|
await startSession(controller, "second");
|
|
118
|
-
second.bridge.destroy();
|
|
119
176
|
|
|
120
177
|
expect(first.stop.destroy).not.toHaveBeenCalled();
|
|
121
178
|
|
|
@@ -125,6 +182,8 @@ describe("WASM controller session lifecycle", () => {
|
|
|
125
182
|
expect(second.stop).toHaveBeenCalledOnce();
|
|
126
183
|
expect(first.stop.destroy).toHaveBeenCalledOnce();
|
|
127
184
|
expect(second.stop.destroy).toHaveBeenCalledOnce();
|
|
185
|
+
expect(first.bridge.destroy).toHaveBeenCalledOnce();
|
|
186
|
+
expect(second.bridge.destroy).toHaveBeenCalledOnce();
|
|
128
187
|
});
|
|
129
188
|
|
|
130
189
|
it("continues stopping sessions after one stop fails", async () => {
|
|
@@ -3,7 +3,7 @@ import type { PyodideInterface } from "pyodide";
|
|
|
3
3
|
import { defaultUserConfig } from "@/core/config/config-schema";
|
|
4
4
|
import { DefaultWasmController } from "@/core/wasm/worker/bootstrap";
|
|
5
5
|
import { WasmFileSystem } from "@/core/wasm/worker/fs";
|
|
6
|
-
import type {
|
|
6
|
+
import type { WasmSession } from "@/core/wasm/worker/types";
|
|
7
7
|
import { Logger } from "../../../utils/Logger";
|
|
8
8
|
|
|
9
9
|
export class ReadonlyWasmController extends DefaultWasmController {
|
|
@@ -35,17 +35,15 @@ export class ReadonlyWasmController extends DefaultWasmController {
|
|
|
35
35
|
return { code, filename };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
public override async
|
|
38
|
+
public override async prepareSession(opts: {
|
|
39
39
|
code: string;
|
|
40
40
|
filename: string | null;
|
|
41
|
-
}): Promise<
|
|
42
|
-
|
|
41
|
+
}): Promise<WasmSession> {
|
|
42
|
+
return super.prepareSession({
|
|
43
43
|
queryParameters: {},
|
|
44
44
|
code: opts.code,
|
|
45
45
|
filename: opts.filename,
|
|
46
46
|
userConfig: defaultUserConfig(),
|
|
47
47
|
});
|
|
48
|
-
|
|
49
|
-
return bridge;
|
|
50
48
|
}
|
|
51
49
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import type { PyodideInterface } from "pyodide";
|
|
4
|
-
import type { PyProxy } from "pyodide/ffi";
|
|
5
4
|
import {
|
|
6
5
|
createRPC,
|
|
7
6
|
createRPCRequestHandler,
|
|
@@ -56,8 +55,6 @@ interface SessionRequest {
|
|
|
56
55
|
let activeSession:
|
|
57
56
|
| (Omit<SessionRequest, "code"> & { bridge: SerializedBridge })
|
|
58
57
|
| undefined;
|
|
59
|
-
/** Every running session's bridge, kept while the worker follows its notebook. */
|
|
60
|
-
const sessions = new Set<SerializedBridge>();
|
|
61
58
|
let sessionQueue = Promise.resolve();
|
|
62
59
|
|
|
63
60
|
async function startSession(
|
|
@@ -75,15 +72,22 @@ async function startSession(
|
|
|
75
72
|
code: opts.code,
|
|
76
73
|
filename: `app-${opts.appId}.py`,
|
|
77
74
|
});
|
|
78
|
-
const
|
|
79
|
-
sessions.add(nextBridge);
|
|
75
|
+
const session = await self.controller.prepareSession(notebook);
|
|
80
76
|
// Follow before the session instantiates. Each subscription begins with
|
|
81
77
|
// its own kernel-ready and the session's retained notifications.
|
|
82
|
-
|
|
78
|
+
try {
|
|
79
|
+
await follow(session.bridge, opts.sessionGeneration);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await session.stop();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
void session.start().catch((error: unknown) => {
|
|
85
|
+
rpc.send.initializedError({ error: prettyError(error) });
|
|
86
|
+
});
|
|
83
87
|
if (!activeSession) {
|
|
84
88
|
activeSession = {
|
|
85
89
|
appId: opts.appId,
|
|
86
|
-
bridge:
|
|
90
|
+
bridge: session.bridge,
|
|
87
91
|
sessionGeneration: opts.sessionGeneration,
|
|
88
92
|
};
|
|
89
93
|
}
|
|
@@ -100,10 +104,6 @@ async function stopActiveSession(): Promise<void> {
|
|
|
100
104
|
// Stopping closes every session and its notebook subscriptions.
|
|
101
105
|
await self.controller.stopSession();
|
|
102
106
|
activeSession = undefined;
|
|
103
|
-
for (const bridge of sessions) {
|
|
104
|
-
(bridge as unknown as PyProxy).destroy();
|
|
105
|
-
}
|
|
106
|
-
sessions.clear();
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
async function follow(
|
|
@@ -11,6 +11,9 @@ const {
|
|
|
11
11
|
mockSaveNotebook,
|
|
12
12
|
mockBootstrap,
|
|
13
13
|
rpcListeners,
|
|
14
|
+
mockNotebookInfo,
|
|
15
|
+
mockNotebookSubscribe,
|
|
16
|
+
mockNotebookUnsubscribe,
|
|
14
17
|
} = vi.hoisted(() => ({
|
|
15
18
|
mockBridge: vi.fn(),
|
|
16
19
|
mockNotebookReadFile: vi.fn(),
|
|
@@ -18,6 +21,9 @@ const {
|
|
|
18
21
|
mockSaveNotebook: vi.fn(),
|
|
19
22
|
mockBootstrap: vi.fn(),
|
|
20
23
|
rpcListeners: {} as Record<string, () => void>,
|
|
24
|
+
mockNotebookInfo: vi.fn(),
|
|
25
|
+
mockNotebookSubscribe: vi.fn(),
|
|
26
|
+
mockNotebookUnsubscribe: vi.fn(),
|
|
21
27
|
}));
|
|
22
28
|
|
|
23
29
|
// Mock browser globals before any imports
|
|
@@ -50,6 +56,9 @@ vi.mock("@/core/wasm/rpc", () => ({
|
|
|
50
56
|
readFile: vi.fn(),
|
|
51
57
|
readNotebook: mockReadNotebook,
|
|
52
58
|
saveNotebook: mockSaveNotebook,
|
|
59
|
+
notebookInfo: mockNotebookInfo,
|
|
60
|
+
notebookSubscribe: mockNotebookSubscribe,
|
|
61
|
+
notebookUnsubscribe: mockNotebookUnsubscribe,
|
|
53
62
|
},
|
|
54
63
|
},
|
|
55
64
|
addMessageListener: (event: string, cb: () => void) => {
|
|
@@ -217,6 +226,39 @@ describe("PyodideBridge.sendSave", () => {
|
|
|
217
226
|
});
|
|
218
227
|
});
|
|
219
228
|
|
|
229
|
+
it("cancels a pending notebook subscription before its RPC response", async () => {
|
|
230
|
+
vi.clearAllMocks();
|
|
231
|
+
store.set(initialModeAtom, "read");
|
|
232
|
+
rpcListeners.initialized();
|
|
233
|
+
const pending = new Deferred<void>();
|
|
234
|
+
mockNotebookInfo.mockResolvedValue({ id: "n1" });
|
|
235
|
+
mockNotebookSubscribe.mockReturnValue(pending.promise);
|
|
236
|
+
mockNotebookUnsubscribe.mockResolvedValue(undefined);
|
|
237
|
+
const controller = new AbortController();
|
|
238
|
+
try {
|
|
239
|
+
const backend = await PyodideBridge.INSTANCE.createNotebookBackend();
|
|
240
|
+
const following = backend.transport(
|
|
241
|
+
{ cursor: null, signal: controller.signal },
|
|
242
|
+
{ open: vi.fn(), message: vi.fn() },
|
|
243
|
+
);
|
|
244
|
+
await vi.waitFor(() =>
|
|
245
|
+
expect(mockNotebookSubscribe).toHaveBeenCalledOnce(),
|
|
246
|
+
);
|
|
247
|
+
const [{ id }] = mockNotebookSubscribe.mock.calls[0];
|
|
248
|
+
controller.abort();
|
|
249
|
+
await following;
|
|
250
|
+
expect(mockNotebookUnsubscribe.mock.calls).toEqual([[id]]);
|
|
251
|
+
|
|
252
|
+
pending.resolve();
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
254
|
+
expect(mockNotebookUnsubscribe.mock.calls).toEqual([[id]]);
|
|
255
|
+
} finally {
|
|
256
|
+
controller.abort();
|
|
257
|
+
pending.resolve();
|
|
258
|
+
store.set(initialModeAtom, undefined);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
220
262
|
describe("getWasmWorkerName", () => {
|
|
221
263
|
afterEach(() => {
|
|
222
264
|
delete (window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean })
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { PyodideInterface } from "pyodide";
|
|
4
|
+
import { loadPyodide } from "pyodide";
|
|
5
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
6
|
+
import { defaultUserConfig } from "@/core/config/config-schema";
|
|
7
|
+
import { Deferred } from "@/utils/Deferred";
|
|
8
|
+
import { DefaultWasmController } from "../worker/bootstrap";
|
|
9
|
+
import { getController } from "../worker/getController";
|
|
10
|
+
import type { WasmRuntimeConfig } from "../runtime-config";
|
|
11
|
+
|
|
12
|
+
interface WorkerRequests {
|
|
13
|
+
startSession(opts: {
|
|
14
|
+
queryParameters: Record<string, string | string[]>;
|
|
15
|
+
code: string;
|
|
16
|
+
filename: string;
|
|
17
|
+
userConfig: ReturnType<typeof defaultUserConfig>;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
notebookSubscribe(opts: { id: string; cursor: string | null }): Promise<void>;
|
|
20
|
+
notebookUnsubscribe(id: string): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { rpc, handlers } = vi.hoisted(() => ({
|
|
24
|
+
handlers: {} as {
|
|
25
|
+
requests: WorkerRequests;
|
|
26
|
+
bootstrap: (config: WasmRuntimeConfig) => void;
|
|
27
|
+
},
|
|
28
|
+
rpc: {
|
|
29
|
+
send: Object.assign(vi.fn(), {
|
|
30
|
+
initialized: vi.fn(),
|
|
31
|
+
initializedError: vi.fn(),
|
|
32
|
+
initializingMessage: vi.fn(),
|
|
33
|
+
notebookEvent: vi.fn(),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("rpc-anywhere", () => ({
|
|
39
|
+
createRPCRequestHandler: (requests: WorkerRequests) => {
|
|
40
|
+
handlers.requests = requests;
|
|
41
|
+
return requests;
|
|
42
|
+
},
|
|
43
|
+
createWorkerParentTransport: vi.fn(),
|
|
44
|
+
createRPC: () => ({
|
|
45
|
+
...rpc,
|
|
46
|
+
addMessageListener: (
|
|
47
|
+
_name: string,
|
|
48
|
+
listener: (config: WasmRuntimeConfig) => void,
|
|
49
|
+
) => {
|
|
50
|
+
handlers.bootstrap = listener;
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
}));
|
|
54
|
+
vi.mock("pyodide", () => ({ loadPyodide: vi.fn(), version: "314.0.0" }));
|
|
55
|
+
vi.mock("../worker/getController", () => ({ getController: vi.fn() }));
|
|
56
|
+
vi.mock("../worker/getMarimoWheel", () => ({
|
|
57
|
+
getMarimoWheel: () => "marimo-base",
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
vi.resetModules();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
async function startWorker(subscribing = Promise.resolve()) {
|
|
67
|
+
const subscribers = new Map<string, (event: string) => void>();
|
|
68
|
+
const event = JSON.stringify({
|
|
69
|
+
event: "marimo",
|
|
70
|
+
data: { marimo: { op: "query-params-set", key: "startup", value: "ran" } },
|
|
71
|
+
});
|
|
72
|
+
const notebook = {
|
|
73
|
+
subscribe: vi.fn(
|
|
74
|
+
async (id: string, _cursor: unknown, emit: (event: string) => void) => {
|
|
75
|
+
await subscribing;
|
|
76
|
+
subscribers.set(id, emit);
|
|
77
|
+
},
|
|
78
|
+
),
|
|
79
|
+
unsubscribe: (id: string) => subscribers.delete(id),
|
|
80
|
+
destroy: vi.fn(),
|
|
81
|
+
};
|
|
82
|
+
const bridge = { notebook, destroy: vi.fn() };
|
|
83
|
+
const init = Object.assign(
|
|
84
|
+
vi.fn(() => subscribers.forEach((emit) => emit(event))),
|
|
85
|
+
{ destroy: vi.fn() },
|
|
86
|
+
);
|
|
87
|
+
const stop = Object.assign(
|
|
88
|
+
vi.fn(async () => subscribers.clear()),
|
|
89
|
+
{
|
|
90
|
+
destroy: vi.fn(),
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
const packages = { toJs: () => [], destroy: vi.fn() };
|
|
94
|
+
const resources = Object.assign([bridge, init, packages, stop], {
|
|
95
|
+
destroy: vi.fn(),
|
|
96
|
+
});
|
|
97
|
+
const pyodide = {
|
|
98
|
+
runPython: vi.fn(() => resources),
|
|
99
|
+
loadPackagesFromImports: vi.fn(async () => undefined),
|
|
100
|
+
loadedPackages: {},
|
|
101
|
+
};
|
|
102
|
+
vi.mocked(loadPyodide).mockResolvedValue(
|
|
103
|
+
pyodide as unknown as PyodideInterface,
|
|
104
|
+
);
|
|
105
|
+
const controller = new DefaultWasmController();
|
|
106
|
+
vi.spyOn(controller, "mountFilesystem").mockResolvedValue({
|
|
107
|
+
code: "",
|
|
108
|
+
filename: "notebook.py",
|
|
109
|
+
});
|
|
110
|
+
vi.mocked(getController).mockResolvedValue(controller);
|
|
111
|
+
await import("../worker/worker");
|
|
112
|
+
handlers.bootstrap({ version: "0.25.1" });
|
|
113
|
+
await handlers.requests.startSession({
|
|
114
|
+
code: "",
|
|
115
|
+
filename: "notebook.py",
|
|
116
|
+
queryParameters: {},
|
|
117
|
+
userConfig: defaultUserConfig(),
|
|
118
|
+
});
|
|
119
|
+
return { controller, init, notebook, event, pyodide };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it("delivers startup notifications after a delayed first subscription, without rerunning on resubscribe", async () => {
|
|
123
|
+
const { controller, init, event, pyodide } = await startWorker();
|
|
124
|
+
try {
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
126
|
+
expect(rpc.send.initialized).toHaveBeenCalledOnce();
|
|
127
|
+
expect(pyodide.loadPackagesFromImports).toHaveResolved();
|
|
128
|
+
expect(init).not.toHaveBeenCalled();
|
|
129
|
+
|
|
130
|
+
await handlers.requests.notebookSubscribe({ id: "first", cursor: null });
|
|
131
|
+
await vi.waitFor(() => expect(init).toHaveBeenCalledOnce());
|
|
132
|
+
expect(rpc.send.notebookEvent.mock.calls).toEqual([
|
|
133
|
+
[{ id: "first", event }],
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
await handlers.requests.notebookUnsubscribe("first");
|
|
137
|
+
await handlers.requests.notebookSubscribe({ id: "second", cursor: null });
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
139
|
+
expect(init).toHaveBeenCalledOnce();
|
|
140
|
+
} finally {
|
|
141
|
+
await controller.stopSession();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("does not authorize startup through a subscription canceled while opening", async () => {
|
|
146
|
+
const subscribing = new Deferred<void>();
|
|
147
|
+
const { controller, init, notebook } = await startWorker(subscribing.promise);
|
|
148
|
+
try {
|
|
149
|
+
const opened = handlers.requests.notebookSubscribe({
|
|
150
|
+
id: "canceled",
|
|
151
|
+
cursor: null,
|
|
152
|
+
});
|
|
153
|
+
await vi.waitFor(() => expect(notebook.subscribe).toHaveBeenCalledOnce());
|
|
154
|
+
const closed = handlers.requests.notebookUnsubscribe("canceled");
|
|
155
|
+
subscribing.resolve();
|
|
156
|
+
await Promise.all([opened, closed]);
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
158
|
+
expect(init).not.toHaveBeenCalled();
|
|
159
|
+
|
|
160
|
+
await handlers.requests.notebookSubscribe({ id: "active", cursor: null });
|
|
161
|
+
await vi.waitFor(() => expect(init).toHaveBeenCalledOnce());
|
|
162
|
+
} finally {
|
|
163
|
+
await controller.stopSession();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("waits for a successful subscription after an opening failure", async () => {
|
|
168
|
+
const { controller, init, notebook } = await startWorker();
|
|
169
|
+
try {
|
|
170
|
+
notebook.subscribe.mockRejectedValueOnce(new Error("Subscription failed"));
|
|
171
|
+
await expect(
|
|
172
|
+
handlers.requests.notebookSubscribe({ id: "failed", cursor: null }),
|
|
173
|
+
).rejects.toThrow("Subscription failed");
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
175
|
+
expect(init).not.toHaveBeenCalled();
|
|
176
|
+
|
|
177
|
+
await handlers.requests.notebookSubscribe({ id: "active", cursor: null });
|
|
178
|
+
await vi.waitFor(() => expect(init).toHaveBeenCalledOnce());
|
|
179
|
+
} finally {
|
|
180
|
+
await controller.stopSession();
|
|
181
|
+
}
|
|
182
|
+
});
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { Deferred } from "@/utils/Deferred";
|
|
|
8
8
|
import { throwNotImplemented } from "@/utils/functions";
|
|
9
9
|
import { Logger } from "@/utils/Logger";
|
|
10
10
|
import { reloadSafe } from "@/utils/reload-safe";
|
|
11
|
+
import { once } from "@/utils/once";
|
|
11
12
|
import { generateUUID } from "@/utils/uuid";
|
|
12
13
|
import { createModuleWorker } from "@/utils/worker";
|
|
13
14
|
import { notebookIsRunningAtom } from "../cells/cells";
|
|
@@ -211,16 +212,19 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
211
212
|
const { id } = await this.rpc.proxy.request.notebookInfo();
|
|
212
213
|
return createWasmNotebookBackend({
|
|
213
214
|
notebookId: id,
|
|
214
|
-
subscribe: async (cursor, emit) => {
|
|
215
|
+
subscribe: async (cursor, emit, signal) => {
|
|
215
216
|
await this.initialized.promise;
|
|
217
|
+
signal.throwIfAborted();
|
|
216
218
|
const id = generateUUID();
|
|
217
219
|
this.notebookConsumers.set(id, emit);
|
|
218
|
-
const close = () => {
|
|
220
|
+
const close = once(() => {
|
|
221
|
+
signal.removeEventListener("abort", close);
|
|
219
222
|
this.notebookConsumers.delete(id);
|
|
220
223
|
void this.rpc.proxy.request
|
|
221
224
|
.notebookUnsubscribe(id)
|
|
222
225
|
.catch(Logger.error);
|
|
223
|
-
};
|
|
226
|
+
});
|
|
227
|
+
signal.addEventListener("abort", close, { once: true });
|
|
224
228
|
try {
|
|
225
229
|
await this.rpc.proxy.request.notebookSubscribe({ id, cursor });
|
|
226
230
|
return close;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { loadPyodide, type PyodideInterface } from "pyodide";
|
|
3
3
|
import type { PyCallable, PyProxy } from "pyodide/ffi";
|
|
4
|
-
import type { UserConfig } from "@/core/config/config-schema";
|
|
5
4
|
import { invariant } from "../../../utils/invariant";
|
|
5
|
+
import { once } from "../../../utils/once";
|
|
6
6
|
import { Logger } from "../../../utils/Logger";
|
|
7
7
|
import { WasmFileSystem } from "./fs";
|
|
8
8
|
import { getMarimoWheel } from "./getMarimoWheel";
|
|
9
9
|
import { t } from "./tracer";
|
|
10
|
-
import type { SerializedBridge, WasmController } from "./types";
|
|
10
|
+
import type { SerializedBridge, WasmController, WasmSession } from "./types";
|
|
11
11
|
import { shouldLoadDuckDBPackages } from "../utils";
|
|
12
12
|
|
|
13
13
|
const MAKE_SNAPSHOT = false;
|
|
@@ -43,7 +43,7 @@ export class DefaultWasmController implements WasmController {
|
|
|
43
43
|
protected pyodide: PyodideInterface | null = null;
|
|
44
44
|
private packageLoadQueue = Promise.resolve();
|
|
45
45
|
private sessionGeneration = 0;
|
|
46
|
-
private
|
|
46
|
+
private sessions = new Set<WasmSession>();
|
|
47
47
|
|
|
48
48
|
public get requirePyodide() {
|
|
49
49
|
invariant(this.pyodide, "Pyodide not loaded");
|
|
@@ -136,18 +136,15 @@ export class DefaultWasmController implements WasmController {
|
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
public async
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
filename: string | null;
|
|
143
|
-
userConfig: UserConfig;
|
|
144
|
-
}): Promise<SerializedBridge> {
|
|
139
|
+
public async prepareSession(
|
|
140
|
+
opts: Parameters<WasmController["prepareSession"]>[0],
|
|
141
|
+
): Promise<WasmSession> {
|
|
145
142
|
const sessionGeneration = this.sessionGeneration;
|
|
146
143
|
const { code, filename, queryParameters, userConfig } = opts;
|
|
147
144
|
self.query_params = queryParameters;
|
|
148
145
|
self.user_config = userConfig;
|
|
149
146
|
|
|
150
|
-
const span = t.startSpan("
|
|
147
|
+
const span = t.startSpan("prepareSession.runPython");
|
|
151
148
|
const nbFilename = filename || WasmFileSystem.NOTEBOOK_FILENAME;
|
|
152
149
|
const sessionResources = this.requirePyodide.runPython(
|
|
153
150
|
`
|
|
@@ -222,45 +219,65 @@ export class DefaultWasmController implements WasmController {
|
|
|
222
219
|
} finally {
|
|
223
220
|
packagesProxy.destroy();
|
|
224
221
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
// a sense of responsiveness.
|
|
222
|
+
let stopped = false;
|
|
223
|
+
let stopping: Promise<void> | undefined;
|
|
224
|
+
const releaseInit = once(() => initSession.destroy());
|
|
225
|
+
const isStopped = () =>
|
|
226
|
+
stopped || sessionGeneration !== this.sessionGeneration;
|
|
227
|
+
// Load in parallel with subscription setup; only start() executes cells.
|
|
232
228
|
const dependenciesReady = this.packageLoadQueue.then(() => {
|
|
233
|
-
if (
|
|
229
|
+
if (isStopped()) {
|
|
234
230
|
return;
|
|
235
231
|
}
|
|
236
232
|
return this.loadNotebookDeps(code, foundPackages);
|
|
237
233
|
});
|
|
238
234
|
this.packageLoadQueue = dependenciesReady.catch(() => undefined);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
235
|
+
const session: WasmSession = {
|
|
236
|
+
bridge: bridgeProxy as unknown as SerializedBridge,
|
|
237
|
+
start: once(async () => {
|
|
238
|
+
try {
|
|
239
|
+
if (isStopped()) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
await dependenciesReady;
|
|
243
|
+
if (!isStopped()) {
|
|
244
|
+
initSession(userConfig.runtime.auto_instantiate);
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (!isStopped()) {
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
releaseInit();
|
|
243
252
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
253
|
+
}),
|
|
254
|
+
stop: () => {
|
|
255
|
+
stopped = true;
|
|
256
|
+
releaseInit();
|
|
257
|
+
stopping ??= (async () => {
|
|
258
|
+
await stopSession();
|
|
259
|
+
stopSession.destroy();
|
|
260
|
+
bridgeProxy.destroy();
|
|
261
|
+
this.sessions.delete(session);
|
|
262
|
+
})().catch((error: unknown) => {
|
|
263
|
+
stopping = undefined;
|
|
264
|
+
throw error;
|
|
265
|
+
});
|
|
266
|
+
return stopping;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
this.sessions.add(session);
|
|
270
|
+
return session;
|
|
252
271
|
}
|
|
253
272
|
|
|
254
273
|
public async stopSession(): Promise<void> {
|
|
255
274
|
this.sessionGeneration += 1;
|
|
256
|
-
const
|
|
275
|
+
const sessions = [...this.sessions];
|
|
257
276
|
let hasFailure = false;
|
|
258
277
|
let firstFailure: unknown;
|
|
259
|
-
for (const
|
|
278
|
+
for (const session of sessions) {
|
|
260
279
|
try {
|
|
261
|
-
await stop();
|
|
262
|
-
this.activeSessionStops.delete(stop);
|
|
263
|
-
stop.destroy();
|
|
280
|
+
await session.stop();
|
|
264
281
|
} catch (error) {
|
|
265
282
|
if (!hasFailure) {
|
|
266
283
|
hasFailure = true;
|
|
@@ -54,18 +54,21 @@ export interface WasmController {
|
|
|
54
54
|
code: string;
|
|
55
55
|
filename: string | null;
|
|
56
56
|
}): Promise<{ code: string; filename: string }>;
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
* @param opts.code - The code to start with
|
|
60
|
-
* @param opts.fallbackCode - The code to fallback to
|
|
61
|
-
* @param opts.filename - The filename to start with
|
|
62
|
-
*/
|
|
63
|
-
startSession(opts: {
|
|
57
|
+
/** Prepare a session and load its dependencies without executing cells. */
|
|
58
|
+
prepareSession(opts: {
|
|
64
59
|
queryParameters: Record<string, string | string[]>;
|
|
65
60
|
code: string;
|
|
66
61
|
filename: string | null;
|
|
67
62
|
userConfig: UserConfig;
|
|
68
|
-
}): Promise<
|
|
63
|
+
}): Promise<WasmSession>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WasmSession {
|
|
67
|
+
readonly bridge: SerializedBridge;
|
|
68
|
+
/** Start once dependencies are ready. Repeated calls share the same start. */
|
|
69
|
+
start(): Promise<void>;
|
|
70
|
+
/** Prevent a pending start and release the session and its bridge. */
|
|
71
|
+
stop(): Promise<void>;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export interface RawBridge {
|