@marimo-team/islands 0.23.15-dev30 → 0.23.15-dev32

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.
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import type { PyodideInterface } from "pyodide";
4
+ import type { PyProxy } from "pyodide/ffi";
4
5
  import {
5
6
  createRPC,
6
7
  createRPCRequestHandler,
@@ -16,7 +17,6 @@ import { MessageBuffer } from "@/core/wasm/worker/message-buffer";
16
17
  import type { RawBridge, SerializedBridge } from "@/core/wasm/worker/types";
17
18
  import type { JsonString } from "@/utils/json/base64";
18
19
  import { Logger } from "@/utils/Logger";
19
- import { Deferred } from "../../../utils/Deferred";
20
20
  import { prettyError } from "../../../utils/errors";
21
21
  import { invariant } from "../../../utils/invariant";
22
22
  import { ReadonlyWasmController } from "./controller";
@@ -50,59 +50,128 @@ const messageBuffer = new MessageBuffer(
50
50
  rpc.send.kernelMessage({ message });
51
51
  },
52
52
  );
53
- const bridgeReady = new Deferred<SerializedBridge>();
53
+ interface SessionRequest {
54
+ code: string;
55
+ appId: string;
56
+ sessionGeneration: number;
57
+ }
58
+
59
+ let activeSession:
60
+ | (Omit<SessionRequest, "code"> & { bridge: SerializedBridge })
61
+ | undefined;
62
+ let sessionQueue = Promise.resolve();
63
+
64
+ async function startSession(
65
+ opts: SessionRequest,
66
+ replace: boolean,
67
+ ): Promise<void> {
68
+ await pyodideReadyPromise;
69
+
70
+ try {
71
+ invariant(self.controller, "Controller not loaded");
72
+ if (replace) {
73
+ await stopActiveSession();
74
+ }
75
+ const notebook = await self.controller.mountFilesystem({
76
+ code: opts.code,
77
+ filename: `app-${opts.appId}.py`,
78
+ });
79
+ const nextBridge = await self.controller.startSession({
80
+ ...notebook,
81
+ onMessage: messageBuffer.push,
82
+ });
83
+ if (activeSession) {
84
+ (nextBridge as unknown as PyProxy).destroy();
85
+ } else {
86
+ activeSession = {
87
+ appId: opts.appId,
88
+ bridge: nextBridge,
89
+ sessionGeneration: opts.sessionGeneration,
90
+ };
91
+ }
92
+ rpc.send.initialized({});
93
+ } catch (error) {
94
+ rpc.send.initializedError({
95
+ error: prettyError(error),
96
+ });
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ async function stopActiveSession(): Promise<void> {
102
+ const session = activeSession;
103
+ await self.controller.stopSession();
104
+ activeSession = undefined;
105
+ (session?.bridge as unknown as PyProxy | undefined)?.destroy();
106
+ }
107
+
108
+ function enqueueSession<T>(operation: () => Promise<T>): Promise<T> {
109
+ const result = sessionQueue.then(operation);
110
+ sessionQueue = result.then(
111
+ () => undefined,
112
+ () => undefined,
113
+ );
114
+ return result;
115
+ }
54
116
 
55
117
  // Handle RPC requests
56
118
  const requestHandler = createRPCRequestHandler({
57
119
  /**
58
120
  * Start the session
59
121
  */
60
- startSession: async (opts: { code: string; appId: string }) => {
61
- await pyodideReadyPromise; // Make sure loading is done
122
+ startSession: async (opts: SessionRequest) => {
123
+ await enqueueSession(() => startSession(opts, false));
124
+ },
62
125
 
63
- try {
126
+ replaceSession: async (opts: SessionRequest) => {
127
+ await enqueueSession(() => startSession(opts, true));
128
+ },
129
+
130
+ stopSession: async (opts: { appId: string; sessionGeneration: number }) => {
131
+ await enqueueSession(async () => {
132
+ if (
133
+ activeSession?.appId !== opts.appId ||
134
+ activeSession?.sessionGeneration !== opts.sessionGeneration
135
+ ) {
136
+ return;
137
+ }
64
138
  invariant(self.controller, "Controller not loaded");
65
- const notebook = await self.controller.mountFilesystem({
66
- code: opts.code,
67
- filename: `app-${opts.appId}.py`,
68
- });
69
- const bridge = await self.controller.startSession({
70
- ...notebook,
71
- onMessage: messageBuffer.push,
72
- });
73
- bridgeReady.resolve(bridge);
74
- rpc.send.initialized({});
75
- } catch (error) {
76
- rpc.send.initializedError({
77
- error: prettyError(error),
78
- });
79
- }
80
- return;
139
+ await stopActiveSession();
140
+ });
81
141
  },
82
142
 
83
143
  /**
84
144
  * Load packages
85
145
  */
86
- loadPackages: async (code: string) => {
146
+ loadPackages: async (opts: {
147
+ appId: string;
148
+ code: string;
149
+ sessionGeneration: number;
150
+ }) => {
87
151
  await pyodideReadyPromise; // Make sure loading is done
152
+ await enqueueSession(async () => {
153
+ requireActiveBridge(opts);
154
+
155
+ let { code } = opts;
88
156
 
89
- if (shouldLoadDuckDBPackages(code)) {
90
- // Add pandas and duckdb to the code for mo.sql and for remote duckdb sources
91
- code = `import pandas\n${code}`;
92
- code = `import duckdb\n${code}`;
93
- code = `import sqlglot\n${code}`;
94
-
95
- // Polars + SQL requires pyarrow, and installing
96
- // after notebook load does not work. As a heuristic,
97
- // if it appears that the notebook uses polars, add pyarrow.
98
- if (code.includes("polars")) {
99
- code = `import pyarrow\n${code}`;
157
+ if (shouldLoadDuckDBPackages(code)) {
158
+ // Add pandas and duckdb to the code for mo.sql and for remote duckdb sources
159
+ code = `import pandas\n${code}`;
160
+ code = `import duckdb\n${code}`;
161
+ code = `import sqlglot\n${code}`;
162
+
163
+ // Polars + SQL requires pyarrow, and installing
164
+ // after notebook load does not work. As a heuristic,
165
+ // if it appears that the notebook uses polars, add pyarrow.
166
+ if (code.includes("polars")) {
167
+ code = `import pyarrow\n${code}`;
168
+ }
100
169
  }
101
- }
102
170
 
103
- await self.pyodide.loadPackagesFromImports(code, {
104
- messageCallback: Logger.log,
105
- errorCallback: Logger.error,
171
+ await self.pyodide.loadPackagesFromImports(code, {
172
+ messageCallback: Logger.log,
173
+ errorCallback: Logger.error,
174
+ });
106
175
  });
107
176
  },
108
177
 
@@ -110,37 +179,56 @@ const requestHandler = createRPCRequestHandler({
110
179
  * Call a function on the bridge
111
180
  */
112
181
  bridge: async (opts: {
182
+ appId: string;
113
183
  functionName: keyof RawBridge;
114
184
  payload: {} | undefined | null;
185
+ sessionGeneration: number;
115
186
  }) => {
116
187
  await pyodideReadyPromise; // Make sure loading is done
117
188
 
118
189
  const { functionName, payload } = opts;
119
190
 
120
191
  // Perform the function call to the Python bridge
121
- const bridge = await bridgeReady.promise;
122
-
123
- // Serialize the payload
124
- const payloadString =
125
- payload == null
126
- ? null
127
- : typeof payload === "string"
128
- ? payload
129
- : JSON.stringify(payload);
130
-
131
- // Make the request
132
- const response =
133
- payloadString == null
134
- ? // @ts-expect-error ehh TypeScript
135
- await bridge[functionName]()
136
- : // @ts-expect-error ehh TypeScript
137
- await bridge[functionName](payloadString);
138
-
139
- // Post the response back to the main thread
140
- return typeof response === "string" ? JSON.parse(response) : response;
192
+ return enqueueSession(async () => {
193
+ const bridge = requireActiveBridge(opts);
194
+
195
+ // Serialize the payload
196
+ const payloadString =
197
+ payload == null
198
+ ? null
199
+ : typeof payload === "string"
200
+ ? payload
201
+ : JSON.stringify(payload);
202
+
203
+ // Make the request
204
+ const response =
205
+ payloadString == null
206
+ ? // @ts-expect-error ehh TypeScript
207
+ await bridge[functionName]()
208
+ : // @ts-expect-error ehh TypeScript
209
+ await bridge[functionName](payloadString);
210
+
211
+ // Post the response back to the main thread
212
+ return typeof response === "string" ? JSON.parse(response) : response;
213
+ });
141
214
  },
142
215
  });
143
216
 
217
+ function requireActiveBridge(opts: {
218
+ appId: string;
219
+ sessionGeneration: number;
220
+ }): SerializedBridge {
221
+ const session = activeSession;
222
+ if (
223
+ !session ||
224
+ opts.appId !== session.appId ||
225
+ opts.sessionGeneration !== session.sessionGeneration
226
+ ) {
227
+ throw new Error("The marimo app session is no longer active.");
228
+ }
229
+ return session.bridge;
230
+ }
231
+
144
232
  // create the iframe's schema
145
233
  export type WorkerSchema = RPCSchema<
146
234
  {
@@ -1,5 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { loadPyodide, type PyodideInterface } from "pyodide";
3
+ import type { PyCallable, PyProxy } from "pyodide/ffi";
3
4
  import type { UserConfig } from "@/core/config/config-schema";
4
5
  import type { NotificationPayload } from "@/core/kernel/messages";
5
6
  import type { JsonString } from "@/utils/json/base64";
@@ -12,6 +13,12 @@ import type { SerializedBridge, WasmController } from "./types";
12
13
  import { shouldLoadDuckDBPackages } from "../utils";
13
14
 
14
15
  const MAKE_SNAPSHOT = false;
16
+ type SessionResources = [
17
+ bridge: PyProxy,
18
+ init: PyCallable,
19
+ packages: PyProxy,
20
+ stop: PyCallable,
21
+ ];
15
22
 
16
23
  // This class initializes the wasm environment
17
24
  // We would like this initialization to be parallelizable
@@ -25,6 +32,9 @@ const MAKE_SNAPSHOT = false;
25
32
 
26
33
  export class DefaultWasmController implements WasmController {
27
34
  protected pyodide: PyodideInterface | null = null;
35
+ private packageLoadQueue = Promise.resolve();
36
+ private sessionGeneration = 0;
37
+ private activeSessionStops = new Set<PyCallable>();
28
38
 
29
39
  get requirePyodide() {
30
40
  invariant(this.pyodide, "Pyodide not loaded");
@@ -111,6 +121,7 @@ export class DefaultWasmController implements WasmController {
111
121
  onMessage: (message: JsonString<NotificationPayload>) => void;
112
122
  userConfig: UserConfig;
113
123
  }): Promise<SerializedBridge> {
124
+ const sessionGeneration = this.sessionGeneration;
114
125
  const { code, filename, onMessage, queryParameters, userConfig } = opts;
115
126
  // We pass down a messenger object to the code
116
127
  // This is used to have synchronous communication between the JS and Python code
@@ -126,47 +137,129 @@ export class DefaultWasmController implements WasmController {
126
137
 
127
138
  const span = t.startSpan("startSession.runPython");
128
139
  const nbFilename = filename || WasmFileSystem.NOTEBOOK_FILENAME;
129
- const [bridge, init, packages] = this.requirePyodide.runPython(
140
+ const sessionResources = this.requirePyodide.runPython(
130
141
  `
131
142
  print("[py] Starting marimo...")
132
143
  import asyncio
144
+ import gc
133
145
  import js
134
146
  from marimo._pyodide.bootstrap import create_session, instantiate
135
147
 
136
148
  assert js.messenger, "messenger is not defined"
137
149
  assert js.query_params, "query_params is not defined"
138
150
 
139
- session, bridge = create_session(
140
- filename="${nbFilename}",
141
- query_params=js.query_params.to_py(),
142
- message_callback=js.messenger.callback,
143
- user_config=js.user_config.to_py(),
144
- )
151
+ def create_session_resources():
152
+ session, bridge = create_session(
153
+ filename="${nbFilename}",
154
+ query_params=js.query_params.to_py(),
155
+ message_callback=js.messenger.callback,
156
+ user_config=js.user_config.to_py(),
157
+ )
158
+ session_task = None
145
159
 
146
- def init(auto_instantiate=True):
147
- instantiate(session, auto_instantiate)
148
- asyncio.create_task(session.start())
160
+ def init(auto_instantiate=True):
161
+ nonlocal session_task
162
+ instantiate(session, auto_instantiate)
163
+ session_task = asyncio.create_task(session.start())
149
164
 
150
- # Find the packages to install
151
- with open("${nbFilename}", "r") as f:
152
- packages = session.find_packages(f.read())
165
+ async def stop():
166
+ nonlocal bridge, session, session_task
167
+ task = session_task
168
+ kernel_task = getattr(session, "kernel_task", None)
169
+ if kernel_task is None:
170
+ if task is not None:
171
+ task.cancel()
172
+ else:
173
+ kernel_task.stop()
174
+ if task is not None:
175
+ await asyncio.gather(task, return_exceptions=True)
176
+ if bridge is not None:
177
+ bridge.session = None
178
+ task = None
179
+ kernel_task = None
180
+ session_task = None
181
+ session = None
182
+ bridge = None
183
+ gc.collect()
153
184
 
154
- bridge, init, packages`,
155
- );
156
- span.end();
185
+ with open("${nbFilename}", "r") as f:
186
+ packages = session.find_packages(f.read())
157
187
 
158
- const foundPackages = new Set<string>(packages.toJs());
188
+ return bridge, init, packages, stop
189
+
190
+ create_session_resources()`,
191
+ ) as PyProxy;
192
+ span.end();
193
+ let bridgeProxy!: PyProxy;
194
+ let initSession!: PyCallable;
195
+ let packagesProxy!: PyProxy;
196
+ let stopSession!: PyCallable;
197
+ try {
198
+ [bridgeProxy, initSession, packagesProxy, stopSession] =
199
+ sessionResources as unknown as SessionResources;
200
+ } finally {
201
+ sessionResources.destroy();
202
+ }
203
+ let foundPackages: Set<string>;
204
+ try {
205
+ foundPackages = new Set<string>(packagesProxy.toJs());
206
+ } catch (error) {
207
+ bridgeProxy.destroy();
208
+ initSession.destroy();
209
+ stopSession.destroy();
210
+ throw error;
211
+ } finally {
212
+ packagesProxy.destroy();
213
+ }
214
+ this.activeSessionStops.add(stopSession);
159
215
 
160
216
  // Fire and forget:
161
217
  // Load notebook dependencies and instantiate the session
162
218
  // We don't want to wait for this to finish,
163
219
  // so we can show the initial code immediately giving
164
220
  // a sense of responsiveness.
165
- void this.loadNotebookDeps(code, foundPackages).then(() => {
166
- return init(userConfig.runtime.auto_instantiate);
221
+ const dependenciesReady = this.packageLoadQueue.then(() => {
222
+ if (sessionGeneration !== this.sessionGeneration) {
223
+ return;
224
+ }
225
+ return this.loadNotebookDeps(code, foundPackages);
167
226
  });
227
+ this.packageLoadQueue = dependenciesReady.catch(() => undefined);
228
+ void dependenciesReady
229
+ .then(() => {
230
+ if (sessionGeneration !== this.sessionGeneration) {
231
+ return;
232
+ }
233
+ return initSession(userConfig.runtime.auto_instantiate);
234
+ })
235
+ .catch((error: unknown) => {
236
+ Logger.error("Failed to load notebook dependencies", error);
237
+ })
238
+ .finally(() => initSession.destroy());
239
+
240
+ return bridgeProxy as unknown as SerializedBridge;
241
+ }
168
242
 
169
- return bridge;
243
+ async stopSession(): Promise<void> {
244
+ this.sessionGeneration += 1;
245
+ const stops = [...this.activeSessionStops];
246
+ let hasFailure = false;
247
+ let firstFailure: unknown;
248
+ for (const stop of stops) {
249
+ try {
250
+ await stop();
251
+ this.activeSessionStops.delete(stop);
252
+ stop.destroy();
253
+ } catch (error) {
254
+ if (!hasFailure) {
255
+ hasFailure = true;
256
+ firstFailure = error;
257
+ }
258
+ }
259
+ }
260
+ if (hasFailure) {
261
+ throw firstFailure;
262
+ }
170
263
  }
171
264
 
172
265
  private async loadNotebookDeps(code: string, foundPackages: Set<string>) {
@@ -1 +0,0 @@
1
- import{t as e}from"./worker-DAWRHcPq.js";var t=e(((e,t)=>{t.exports={}}));export default t();