@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.
Files changed (26) hide show
  1. package/dist/{ErrorBoundary-BX9mTNLJ.js → ErrorBoundary-CTJ1U1fx.js} +1 -1
  2. package/dist/assets/__vite-browser-external-BgQNn4dS.js +1 -0
  3. package/dist/assets/worker-FKe3RHT4.js +113 -0
  4. package/dist/{code-visibility-gptWgGHp.js → code-visibility-C1jZfwPW.js} +1 -1
  5. package/dist/{common-mU-2k6Zp.js → common-CNv_7gvI.js} +1 -1
  6. package/dist/{data-grid-overlay-editor-Bdyx_BTg.js → data-grid-overlay-editor-t0t17DPI.js} +1 -1
  7. package/dist/{feedback-button-BoXM4uir.js → feedback-button-JzaiyZZm.js} +2 -2
  8. package/dist/{glide-data-editor-DaXYMyzk.js → glide-data-editor-B3eq-1SC.js} +3 -3
  9. package/dist/{globals-Bj4btPVp.js → globals-BW8TOLYQ.js} +1 -1
  10. package/dist/main.js +7 -7
  11. package/dist/{number-overlay-editor-DHfzwLIx.js → number-overlay-editor-DoUnDbPE.js} +1 -1
  12. package/dist/{reveal-component-CurCj3ks.js → reveal-component-ClNn1CDl.js} +2 -2
  13. package/package.json +1 -1
  14. package/src/core/host/__tests__/wasm.test.ts +1 -0
  15. package/src/core/host/wasm.ts +10 -5
  16. package/src/core/islands/worker/__tests__/controller.test.ts +62 -3
  17. package/src/core/islands/worker/controller.ts +4 -6
  18. package/src/core/islands/worker/worker.tsx +11 -11
  19. package/src/core/wasm/__tests__/bridge.test.ts +42 -0
  20. package/src/core/wasm/__tests__/worker.test.ts +182 -0
  21. package/src/core/wasm/bridge.ts +7 -3
  22. package/src/core/wasm/worker/bootstrap.ts +52 -35
  23. package/src/core/wasm/worker/types.ts +11 -8
  24. package/src/core/wasm/worker/worker.ts +31 -14
  25. package/dist/assets/__vite-browser-external-DrmlAsRP.js +0 -1
  26. package/dist/assets/worker-O2XUgidQ.js +0 -113
@@ -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(cursor, (event) => {
69
- if (!signal.aborted) {
70
- message(event);
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 startSession(controller: TestController, code: string) {
54
- return controller.startSession({
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 { SerializedBridge } from "@/core/wasm/worker/types";
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 startSession(opts: {
38
+ public override async prepareSession(opts: {
39
39
  code: string;
40
40
  filename: string | null;
41
- }): Promise<SerializedBridge> {
42
- const bridge = super.startSession({
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 nextBridge = await self.controller.startSession(notebook);
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
- await follow(nextBridge, opts.sessionGeneration);
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: nextBridge,
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
+ });
@@ -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 activeSessionStops = new Set<PyCallable>();
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 startSession(opts: {
140
- queryParameters: Record<string, string | string[]>;
141
- code: string;
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("startSession.runPython");
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
- this.activeSessionStops.add(stopSession);
226
-
227
- // Fire and forget:
228
- // Load notebook dependencies and instantiate the session
229
- // We don't want to wait for this to finish,
230
- // so we can show the initial code immediately giving
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 (sessionGeneration !== this.sessionGeneration) {
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
- void dependenciesReady
240
- .then(() => {
241
- if (sessionGeneration !== this.sessionGeneration) {
242
- return;
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
- return initSession(userConfig.runtime.auto_instantiate);
245
- })
246
- .catch((error: unknown) => {
247
- Logger.error("Failed to load notebook dependencies", error);
248
- })
249
- .finally(() => initSession.destroy());
250
-
251
- return bridgeProxy as unknown as SerializedBridge;
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 stops = [...this.activeSessionStops];
275
+ const sessions = [...this.sessions];
257
276
  let hasFailure = false;
258
277
  let firstFailure: unknown;
259
- for (const stop of stops) {
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
- * Start the session
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<SerializedBridge>;
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 {