@nimbus-sh/worker 0.1.4 → 0.1.6

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 (141) hide show
  1. package/README.md +37 -0
  2. package/dist/_shared/crypto.d.ts +15 -0
  3. package/dist/_shared/crypto.d.ts.map +1 -0
  4. package/dist/_shared/crypto.js +122 -0
  5. package/dist/facets/manager.d.ts +24 -0
  6. package/dist/facets/manager.d.ts.map +1 -1
  7. package/dist/facets/manager.js +279 -70
  8. package/dist/facets/process.d.ts +11 -2
  9. package/dist/facets/process.d.ts.map +1 -1
  10. package/dist/facets/process.js +187 -21
  11. package/dist/facets/vite-dev-server.d.ts +2 -0
  12. package/dist/facets/vite-dev-server.d.ts.map +1 -1
  13. package/dist/facets/vite-dev-server.js +39 -12
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/loaders/child-process/spawn-facet.d.ts +1 -3
  18. package/dist/loaders/child-process/spawn-facet.d.ts.map +1 -1
  19. package/dist/loaders/child-process/spawn-facet.js +1 -12
  20. package/dist/loaders/child-process/spawn-pool.d.ts +1 -1
  21. package/dist/loaders/child-process/spawn-pool.d.ts.map +1 -1
  22. package/dist/loaders/child-process/spawn-pool.js +1 -6
  23. package/dist/loaders/loader-pool.d.ts +7 -0
  24. package/dist/loaders/loader-pool.d.ts.map +1 -1
  25. package/dist/loaders/loader-pool.js +3 -1
  26. package/dist/npm/installer.d.ts.map +1 -1
  27. package/dist/npm/installer.js +47 -27
  28. package/dist/npm/pre-bundle-facet.d.ts.map +1 -1
  29. package/dist/npm/pre-bundle-facet.js +2 -1
  30. package/dist/npm/resolve-one-facet.d.ts.map +1 -1
  31. package/dist/npm/resolve-one-facet.js +41 -13
  32. package/dist/npm/resolver.d.ts.map +1 -1
  33. package/dist/npm/resolver.js +70 -35
  34. package/dist/router/index.d.ts.map +1 -1
  35. package/dist/router/index.js +45 -21
  36. package/dist/router/remote-api.d.ts.map +1 -1
  37. package/dist/router/remote-api.js +29 -8
  38. package/dist/runtime/barrel-synthesizer.d.ts +7 -0
  39. package/dist/runtime/barrel-synthesizer.d.ts.map +1 -1
  40. package/dist/runtime/barrel-synthesizer.js +11 -0
  41. package/dist/runtime/clang-runner.d.ts +2 -2
  42. package/dist/runtime/clang-runner.d.ts.map +1 -1
  43. package/dist/runtime/clang-runner.js +64 -47
  44. package/dist/runtime/esbuild-service.d.ts +12 -5
  45. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  46. package/dist/runtime/esbuild-service.js +28 -14
  47. package/dist/runtime/node-runner.d.ts +2 -0
  48. package/dist/runtime/node-runner.d.ts.map +1 -1
  49. package/dist/runtime/node-shims.d.ts +4 -2
  50. package/dist/runtime/node-shims.d.ts.map +1 -1
  51. package/dist/runtime/node-shims.js +404 -55
  52. package/dist/runtime/os-contracts.d.ts +128 -0
  53. package/dist/runtime/os-contracts.d.ts.map +1 -0
  54. package/dist/runtime/os-contracts.js +1 -0
  55. package/dist/runtime/package-manager.d.ts +31 -0
  56. package/dist/runtime/package-manager.d.ts.map +1 -1
  57. package/dist/runtime/package-manager.js +162 -15
  58. package/dist/runtime/port-registry.d.ts +5 -0
  59. package/dist/runtime/port-registry.d.ts.map +1 -1
  60. package/dist/runtime/port-registry.js +22 -1
  61. package/dist/runtime/pyodide-runtime-assets.d.ts +19 -0
  62. package/dist/runtime/pyodide-runtime-assets.d.ts.map +1 -0
  63. package/dist/runtime/pyodide-runtime-assets.js +44 -0
  64. package/dist/runtime/python-repl.d.ts +3 -0
  65. package/dist/runtime/python-repl.d.ts.map +1 -1
  66. package/dist/runtime/python-repl.js +18 -32
  67. package/dist/runtime/python-runner.d.ts +8 -9
  68. package/dist/runtime/python-runner.d.ts.map +1 -1
  69. package/dist/runtime/python-runner.js +958 -118
  70. package/dist/runtime/python-socket-shim.d.ts +11 -0
  71. package/dist/runtime/python-socket-shim.d.ts.map +1 -0
  72. package/dist/runtime/python-socket-shim.js +526 -0
  73. package/dist/runtime/require-resolver.d.ts.map +1 -1
  74. package/dist/runtime/require-resolver.js +2 -11
  75. package/dist/runtime/ruby-gems.d.ts +25 -0
  76. package/dist/runtime/ruby-gems.d.ts.map +1 -0
  77. package/dist/runtime/ruby-gems.js +582 -0
  78. package/dist/runtime/ruby-runner.d.ts +2 -4
  79. package/dist/runtime/ruby-runner.d.ts.map +1 -1
  80. package/dist/runtime/ruby-runner.js +335 -34
  81. package/dist/runtime/runtime-catalog.d.ts +16 -4
  82. package/dist/runtime/runtime-catalog.d.ts.map +1 -1
  83. package/dist/runtime/runtime-catalog.js +21 -10
  84. package/dist/runtime/runtime-registry.d.ts +3 -0
  85. package/dist/runtime/runtime-registry.d.ts.map +1 -1
  86. package/dist/runtime/runtime-registry.js +4 -0
  87. package/dist/runtime/sqlite-runtime-fs-bridge.d.ts +43 -0
  88. package/dist/runtime/sqlite-runtime-fs-bridge.d.ts.map +1 -0
  89. package/dist/runtime/sqlite-runtime-fs-bridge.js +224 -0
  90. package/dist/runtime/static-server.d.ts +3 -0
  91. package/dist/runtime/static-server.d.ts.map +1 -0
  92. package/dist/runtime/static-server.js +121 -0
  93. package/dist/runtime/vfs-snapshot.d.ts +57 -0
  94. package/dist/runtime/vfs-snapshot.d.ts.map +1 -0
  95. package/dist/runtime/vfs-snapshot.js +141 -0
  96. package/dist/runtime/virtual-socket-kernel.d.ts +16 -0
  97. package/dist/runtime/virtual-socket-kernel.d.ts.map +1 -0
  98. package/dist/runtime/virtual-socket-kernel.js +360 -0
  99. package/dist/runtime/wasi-instance.d.ts +6 -3
  100. package/dist/runtime/wasi-instance.d.ts.map +1 -1
  101. package/dist/runtime/wasm-runner.d.ts +1 -25
  102. package/dist/runtime/wasm-runner.d.ts.map +1 -1
  103. package/dist/runtime/wasm-runner.js +1 -160
  104. package/dist/session/agent.d.ts +43 -0
  105. package/dist/session/agent.d.ts.map +1 -0
  106. package/dist/session/agent.js +1252 -0
  107. package/dist/session/init.d.ts.map +1 -1
  108. package/dist/session/init.js +64 -19
  109. package/dist/session/nimbus-session.d.ts +14 -2
  110. package/dist/session/nimbus-session.d.ts.map +1 -1
  111. package/dist/session/nimbus-session.js +104 -30
  112. package/dist/session/programmatic.d.ts +10 -0
  113. package/dist/session/programmatic.d.ts.map +1 -1
  114. package/dist/session/programmatic.js +121 -0
  115. package/dist/session/routes.d.ts.map +1 -1
  116. package/dist/session/routes.js +19 -1
  117. package/dist/session/rpc.d.ts +11 -0
  118. package/dist/session/rpc.d.ts.map +1 -1
  119. package/dist/session/rpc.js +56 -43
  120. package/dist/session/supervisor-rpc.d.ts +15 -0
  121. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  122. package/dist/session/supervisor-rpc.js +32 -0
  123. package/dist/session/ws.d.ts +4 -3
  124. package/dist/session/ws.d.ts.map +1 -1
  125. package/dist/session/ws.js +11 -1
  126. package/dist/shell/unix-commands.d.ts.map +1 -1
  127. package/dist/shell/unix-commands.js +52 -51
  128. package/dist/vfs/path.d.ts +8 -4
  129. package/dist/vfs/path.d.ts.map +1 -1
  130. package/dist/vfs/path.js +16 -4
  131. package/dist/vfs/sqlite-vfs.d.ts +2 -0
  132. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  133. package/dist/vfs/sqlite-vfs.js +11 -0
  134. package/dist/vfs/symlink-registry.d.ts +7 -1
  135. package/dist/vfs/symlink-registry.d.ts.map +1 -1
  136. package/dist/vfs/symlink-registry.js +16 -11
  137. package/package.json +4 -1
  138. package/public/index.html +240 -0
  139. package/public/s/index.html +1316 -128
  140. package/runtime-contracts/pyodide-workerd-adapter.json +18 -0
  141. package/scripts/bundle-runtime.mjs +99 -24
@@ -8,24 +8,56 @@
8
8
  * - exit code via sys.exit(N) or unhandled exception → 1
9
9
  * - argv passed through to sys.argv
10
10
  *
11
- * Out of v1 (queued for v2/v3):
11
+ * Current limits:
12
12
  * - REPL mode (`python` with no args)
13
- * - File I/O beyond reading the entry script
14
- * - `pip install` / `loadPackage` / native-extension packages
13
+ * - Native Linux wheels, runtime-loaded extension modules, and extension builds
15
14
  * - Sync HTTP (urllib3 / requests blocked without JSPI)
16
15
  *
17
16
  * Architecture: SAME LOADER-modules transport as clang-runner/wasm-
18
17
  * runner. The Pyodide wasm bytes ship via the LOADER `modules` map
19
18
  * (CSP allows wasm code-gen at module-load time, not at request
20
- * time). The Pyodide.asm.js + stdlib zip ride via the loader-pool
21
- * `context` field (JSON-stringified into the inner worker.js at
22
- * module-load).
19
+ * time). The workerd-adapted Pyodide.asm.js artifact and stdlib zip ride via
20
+ * the loader-pool `context` field (JSON-stringified into the inner worker.js
21
+ * at module-load).
23
22
  *
24
23
  * Per wasm-csp/findings.md §4b: Pyodide.asm.wasm (10.1 MB on disk)
25
24
  * compiles in 314 ms via LOADER on PROD. With our v1 deployment of
26
25
  * 0.29.4 (8.25 MB asm.wasm), this is well under the empirical
27
26
  * ~32 MiB per-call ceiling.
28
27
  */
28
+ import { flushVfsDiff, snapshotVfs } from './vfs-snapshot.js';
29
+ import { createLiveStaticServerCode, parsePort } from './static-server.js';
30
+ import { parentVfsPath, resolveVfsPath } from '../vfs/path.js';
31
+ import { VIRTUAL_SOCKET_KERNEL_SRC } from './virtual-socket-kernel.js';
32
+ import { PYTHON_SOCKET_SHIM } from './python-socket-shim.js';
33
+ import { readPyodideRuntimeFiles } from './pyodide-runtime-assets.js';
34
+ const PYTHON_SITE_PACKAGES_ROOT = 'home/user/.nimbus-python/site-packages';
35
+ const PYTHON_VERSION_FLAGS = new Set(['--version', '-V']);
36
+ const PYTHON_HELP_FLAGS = new Set(['--help', '-h']);
37
+ const IGNORED_PIP_INSTALL_FLAGS = new Set([
38
+ '--upgrade',
39
+ '-U',
40
+ '--force-reinstall',
41
+ '--no-cache-dir',
42
+ '--user',
43
+ '--disable-pip-version-check',
44
+ '--prefer-binary',
45
+ '--only-binary=:all:',
46
+ ]);
47
+ const PIP_INSTALL_FLAGS_WITH_VALUE = new Set([
48
+ '-i',
49
+ '--index-url',
50
+ '--extra-index-url',
51
+ '-f',
52
+ '--find-links',
53
+ '--trusted-host',
54
+ '--timeout',
55
+ '--retries',
56
+ '--platform',
57
+ '--python-version',
58
+ '--implementation',
59
+ '--abi',
60
+ ]);
29
61
  /**
30
62
  * Build the python-runner factory. Called once at session init; the
31
63
  * returned factory binds the manifest + install root for each
@@ -33,7 +65,7 @@
33
65
  */
34
66
  export function makePythonRunnerFactory(deps) {
35
67
  const { facetMgr, vfs } = deps;
36
- return function pythonRunnerFactory(manifest, installRoot, binName, _binKind) {
68
+ return function pythonRunnerFactory(manifest, installRoot, binName, binKind) {
37
69
  const findFile = (rel) => {
38
70
  const entry = manifest.files.find((f) => f.path === rel);
39
71
  return entry ? `${installRoot}/${entry.path}` : null;
@@ -41,19 +73,38 @@ export function makePythonRunnerFactory(deps) {
41
73
  const asmWasmVfs = findFile('share/pyodide/pyodide.asm.wasm');
42
74
  const asmJsVfs = findFile('share/pyodide/pyodide.asm.js');
43
75
  const stdlibVfs = findFile('share/pyodide/python_stdlib.zip');
76
+ const lockfileVfs = findFile('share/pyodide/pyodide-lock.json');
77
+ let runtimePromise = null;
78
+ let fsSnapshotCache = null;
44
79
  return async function pythonBinHandler(ctx) {
45
80
  const argv = ctx.args || [];
46
81
  const cwd = ctx.cwd || '/home/user';
82
+ const pipInvocation = binKind === 'pip' || binName === 'pip' || binName === 'pip3'
83
+ ? buildPipInvocation(argv, binName, cwd, vfs)
84
+ : buildPythonModulePipInvocation(argv, cwd, vfs);
85
+ if (pipInvocation.error) {
86
+ ctx.stderr.write(`${binName}: ${pipInvocation.error}\n`);
87
+ return pipInvocation.exitCode;
88
+ }
47
89
  // ── --version / --help fast paths (no wasm boot) ─────────────
48
- if (argv.includes('--version') || argv.includes('-V')) {
90
+ if (pipInvocation.mode !== 'pip' && hasLeadingCliFlag(argv, PYTHON_VERSION_FLAGS)) {
49
91
  ctx.stdout.write(`Python 3.13.2 (Pyodide 0.29.4, Nimbus runtime)\n`);
50
92
  return 0;
51
93
  }
52
- if (argv.includes('--help') || argv.includes('-h')) {
94
+ if (pipInvocation.mode !== 'pip' && hasLeadingCliFlag(argv, PYTHON_HELP_FLAGS)) {
53
95
  ctx.stdout.write(`usage: ${binName} [option] ... [-c cmd | -m mod | file | -] [arg] ...\n`);
54
96
  ctx.stdout.write(`Nimbus Pyodide 0.29.4 / Python 3.13 runtime.\n`);
55
- ctx.stdout.write(`Supported v1: -c <code>, <file.py>, stdin via -\n`);
56
- ctx.stdout.write(`Not supported: REPL (no args), pip, native extensions\n`);
97
+ ctx.stdout.write(`Supported: -c <code>, -m <module>, <file.py>, stdin via -, VFS-backed imports and file IO\n`);
98
+ ctx.stdout.write(`Package support is limited to pure Python wheels and packages with pure fallbacks; native Linux wheels and runtime-loaded extension modules are not executable in Nimbus.\n`);
99
+ return 0;
100
+ }
101
+ const staticServer = parsePythonHttpServer(argv, cwd);
102
+ if (staticServer) {
103
+ const code = createLiveStaticServerCode(staticServer.root);
104
+ const command = `${binName} -m http.server ${staticServer.port}`;
105
+ const spawned = facetMgr.spawn(code, command, staticServer.root, { port: staticServer.port });
106
+ ctx.stdout.write(`Serving HTTP on 0.0.0.0 port ${staticServer.port} from /${staticServer.root}\n`);
107
+ ctx.stdout.write(`[nimbus] started: pid=${spawned.pid} cmd="${command}" port=${staticServer.port}\n`);
57
108
  return 0;
58
109
  }
59
110
  // ── Resolve install bytes ────────────────────────────────────
@@ -69,16 +120,21 @@ export function makePythonRunnerFactory(deps) {
69
120
  ctx.stderr.write(`${binName}: python_stdlib.zip missing\n`);
70
121
  return 127;
71
122
  }
72
- const asmWasmBytes = vfs.readFile(asmWasmVfs);
73
- const asmJsBytes = vfs.readFile(asmJsVfs);
74
- const stdlibBytes = vfs.readFile(stdlibVfs);
75
123
  // ── Parse argv ───────────────────────────────────────────────
76
124
  // Supported in v1:
77
125
  // python -c '<code>' run inline code, args[i+1..] in sys.argv
78
126
  // python <file.py> [args...] run script, args in sys.argv
79
127
  // python (no args) → not supported; REPL is v2
80
128
  // python - read code from stdin (advanced)
81
- const parsed = parsePythonArgv(argv);
129
+ const parsed = pipInvocation.mode === 'pip'
130
+ ? {
131
+ mode: 'inline',
132
+ inlineCode: pipInvocation.code,
133
+ scriptPath: '',
134
+ scriptArgs: [],
135
+ exitCode: 0,
136
+ }
137
+ : parsePythonArgv(argv);
82
138
  if (parsed.error) {
83
139
  ctx.stderr.write(`${binName}: ${parsed.error}\n`);
84
140
  return parsed.exitCode;
@@ -132,20 +188,82 @@ export function makePythonRunnerFactory(deps) {
132
188
  userEnv.HOME = '/home/pyodide';
133
189
  if (!userEnv.PYTHONUNBUFFERED)
134
190
  userEnv.PYTHONUNBUFFERED = '1';
191
+ if (userEnv.HOME === '/home/pyodide')
192
+ userEnv.HOME = '/home/user';
193
+ const revision = typeof vfs.revision === 'function' ? vfs.revision() : Date.now();
194
+ let fsSnapshot = fsSnapshotCache && fsSnapshotCache.cwd === cwd && fsSnapshotCache.revision === revision
195
+ ? fsSnapshotCache.result
196
+ : null;
197
+ if (!fsSnapshot) {
198
+ fsSnapshot = snapshotVfs(vfs, cwd, { extraRoots: [PYTHON_SITE_PACKAGES_ROOT] });
199
+ fsSnapshotCache = { cwd, revision, result: fsSnapshot };
200
+ }
201
+ if ('error' in fsSnapshot) {
202
+ ctx.stderr.write(`${binName}: ${fsSnapshot.error}\n`);
203
+ return 1;
204
+ }
205
+ if (shouldRunPythonAsSocketProcess(argv, parsed, pipInvocation.mode === 'pip')) {
206
+ const result = await spawnPythonSocketProcess(facetMgr, {
207
+ asmWasmVfs,
208
+ asmJsVfs,
209
+ stdlibVfs,
210
+ lockfileVfs,
211
+ manifest,
212
+ vfs,
213
+ }, {
214
+ userCode,
215
+ pyArgv,
216
+ userEnv,
217
+ progName,
218
+ cwd,
219
+ fsSnapshot: fsSnapshot.snapshot,
220
+ asyncRun: true,
221
+ pyodidePackages: [],
222
+ }, formatPythonCommand(binName, argv));
223
+ if (result.stdout)
224
+ ctx.stdout.write(result.stdout);
225
+ if (result.stderr)
226
+ ctx.stderr.write(result.stderr);
227
+ if (result.fsDiff)
228
+ flushVfsDiff(vfs, result.fsDiff);
229
+ return result.exitCode;
230
+ }
135
231
  // ── Dispatch the facet ───────────────────────────────────────
136
- const result = await dispatchPythonFacet(facetMgr, {
137
- asmWasmBytes,
138
- asmJsBytes,
139
- stdlibBytes,
232
+ let runtime;
233
+ try {
234
+ if (!runtimePromise) {
235
+ runtimePromise = createPythonFacetRuntime(facetMgr, {
236
+ asmWasmVfs,
237
+ asmJsVfs,
238
+ stdlibVfs,
239
+ lockfileVfs,
240
+ manifest,
241
+ vfs,
242
+ });
243
+ }
244
+ runtime = await runtimePromise;
245
+ }
246
+ catch (e) {
247
+ runtimePromise = null;
248
+ ctx.stderr.write(`${binName}: python runtime warm-up failed: ${e?.message || e}\n`);
249
+ return 1;
250
+ }
251
+ const result = await dispatchPythonFacet(runtime, {
140
252
  userCode,
141
253
  pyArgv,
142
254
  userEnv,
143
255
  progName,
256
+ cwd,
257
+ fsSnapshot: fsSnapshot.snapshot,
258
+ asyncRun: pipInvocation.mode === 'pip',
259
+ pyodidePackages: pipInvocation.mode === 'pip' && /^import micropip/m.test(pipInvocation.code) ? ['micropip'] : [],
144
260
  });
145
261
  if (result.stdout)
146
262
  ctx.stdout.write(result.stdout);
147
263
  if (result.stderr)
148
264
  ctx.stderr.write(result.stderr);
265
+ if (result.fsDiff)
266
+ flushVfsDiff(vfs, result.fsDiff);
149
267
  if (result.error) {
150
268
  ctx.stderr.write(`${binName}: ${result.error}\n`);
151
269
  return 1;
@@ -154,6 +272,386 @@ export function makePythonRunnerFactory(deps) {
154
272
  };
155
273
  };
156
274
  }
275
+ function buildPythonModulePipInvocation(argv, cwd, vfs) {
276
+ if (argv[0] !== '-m' || argv[1] !== 'pip') {
277
+ return { mode: 'none', code: '', exitCode: 0 };
278
+ }
279
+ return buildPipInvocation(argv.slice(2), 'pip', cwd, vfs);
280
+ }
281
+ function buildPipInvocation(argv, binName, cwd, vfs) {
282
+ const wantsVersion = argv.includes('--version') || argv.includes('-V');
283
+ const wantsHelp = argv.length === 0 || argv.includes('--help') || argv.includes('-h');
284
+ if (wantsVersion) {
285
+ return {
286
+ mode: 'pip',
287
+ code: [
288
+ 'print("pip 24.3.1 (Nimbus Pyodide package bridge, Pyodide 0.29.4)")',
289
+ ].join('\n'),
290
+ exitCode: 0,
291
+ };
292
+ }
293
+ if (wantsHelp) {
294
+ return {
295
+ mode: 'pip',
296
+ code: [
297
+ `print(${JSON.stringify(`Usage: ${binName} install <package> [package...]`)})`,
298
+ 'print("Nimbus pip installs pure Python wheels and packages with pure fallbacks.")',
299
+ 'print("Native Linux wheels and runtime-loaded extension modules are not executable in Nimbus.")',
300
+ ].join('\n'),
301
+ exitCode: 0,
302
+ };
303
+ }
304
+ const command = argv[0];
305
+ if (command !== 'install') {
306
+ return {
307
+ mode: 'none',
308
+ code: '',
309
+ error: `pip subcommand '${command || '(none)'}' is not supported yet; supported: install, --version, --help`,
310
+ exitCode: 2,
311
+ };
312
+ }
313
+ const plan = buildPipInstallPlan(argv.slice(1), cwd, vfs);
314
+ if (plan.error) {
315
+ return { mode: 'none', code: '', error: plan.error, exitCode: plan.exitCode };
316
+ }
317
+ return {
318
+ mode: 'pip',
319
+ code: [
320
+ 'import micropip',
321
+ 'import os',
322
+ 'import shutil',
323
+ 'import sys',
324
+ `packages = ${JSON.stringify(plan.packages)}`,
325
+ `display_packages = ${JSON.stringify(plan.displayPackages)}`,
326
+ `install_deps = ${plan.deps ? 'True' : 'False'}`,
327
+ `target_site_packages = "/home/user/.nimbus-python/site-packages"`,
328
+ 'os.makedirs(target_site_packages, exist_ok=True)',
329
+ 'if target_site_packages not in sys.path:',
330
+ ' sys.path.insert(0, target_site_packages)',
331
+ 'source_site_packages = next((p for p in sys.path if isinstance(p, str) and p.startswith("/lib/python") and p.endswith("/site-packages") and os.path.isdir(p)), None)',
332
+ 'before_entries = set(os.listdir(source_site_packages)) if source_site_packages else set()',
333
+ 'def _nimbus_disable_unsupported_extensions():',
334
+ ' disabled = []',
335
+ ' roots = []',
336
+ ' for p in sys.path:',
337
+ ' if isinstance(p, str) and p and "site-packages" in p and os.path.isdir(p) and p not in roots:',
338
+ ' roots.append(p)',
339
+ ' for root in roots:',
340
+ ' for dirpath, _dirnames, filenames in os.walk(root):',
341
+ ' for name in filenames:',
342
+ ' if not name.endswith((".so", ".pyd", ".dylib")):',
343
+ ' continue',
344
+ ' path = os.path.join(dirpath, name)',
345
+ ' try:',
346
+ ' os.remove(path)',
347
+ ' disabled.append(path)',
348
+ ' except Exception:',
349
+ ' pass',
350
+ ' return disabled',
351
+ 'await micropip.install(packages, keep_going=False, deps=install_deps)',
352
+ 'if source_site_packages:',
353
+ ' for name in sorted(set(os.listdir(source_site_packages)) - before_entries):',
354
+ ' src = os.path.join(source_site_packages, name)',
355
+ ' dst = os.path.join(target_site_packages, name)',
356
+ ' if os.path.isdir(dst):',
357
+ ' shutil.rmtree(dst)',
358
+ ' elif os.path.exists(dst):',
359
+ ' os.remove(dst)',
360
+ ' if os.path.isdir(src):',
361
+ ' shutil.copytree(src, dst)',
362
+ ' else:',
363
+ ' shutil.copy2(src, dst)',
364
+ 'disabled_extensions = _nimbus_disable_unsupported_extensions()',
365
+ 'if disabled_extensions:',
366
+ ' names = ", ".join(os.path.basename(p) for p in disabled_extensions[:8])',
367
+ ' if len(disabled_extensions) > 8:',
368
+ ' names += " ..."',
369
+ ' print("Nimbus pip: disabled unsupported Python extension module(s): " + names)',
370
+ 'print("Successfully installed " + " ".join(display_packages))',
371
+ ].join('\n'),
372
+ exitCode: 0,
373
+ };
374
+ }
375
+ function buildPipInstallPlan(argv, cwd, vfs) {
376
+ const packages = [];
377
+ const displayPackages = [];
378
+ let deps = true;
379
+ const addSpec = (rawSpec, baseDir) => {
380
+ const normalized = normalizePipInstallSpec(rawSpec, baseDir, vfs);
381
+ if ('error' in normalized)
382
+ return normalized.error;
383
+ packages.push(normalized.installSpec);
384
+ displayPackages.push(normalized.displaySpec);
385
+ return null;
386
+ };
387
+ for (let i = 0; i < argv.length; i++) {
388
+ const arg = argv[i];
389
+ if (arg === '-r' || arg === '--requirement') {
390
+ const reqPath = argv[i + 1];
391
+ if (!reqPath)
392
+ return { packages, displayPackages, deps, error: `${arg}: missing requirements file`, exitCode: 2 };
393
+ const err = addRequirementsFile(reqPath, cwd, vfs, packages, displayPackages, new Set(), 0);
394
+ if (err)
395
+ return { packages, displayPackages, deps, error: err, exitCode: 1 };
396
+ i++;
397
+ continue;
398
+ }
399
+ if (arg.startsWith('--requirement=')) {
400
+ const reqPath = arg.slice('--requirement='.length);
401
+ const err = addRequirementsFile(reqPath, cwd, vfs, packages, displayPackages, new Set(), 0);
402
+ if (err)
403
+ return { packages, displayPackages, deps, error: err, exitCode: 1 };
404
+ continue;
405
+ }
406
+ if (arg === '--no-deps') {
407
+ deps = false;
408
+ continue;
409
+ }
410
+ if (isIgnoredPipInstallFlag(arg)) {
411
+ continue;
412
+ }
413
+ if (pipFlagTakesValue(arg)) {
414
+ if (!argv[i + 1])
415
+ return { packages, displayPackages, deps, error: `${arg}: missing value`, exitCode: 2 };
416
+ i++;
417
+ continue;
418
+ }
419
+ if (arg.startsWith('-')) {
420
+ return { packages, displayPackages, deps, error: `pip install option '${arg}' is not supported in Nimbus yet`, exitCode: 2 };
421
+ }
422
+ const err = addSpec(arg, cwd);
423
+ if (err)
424
+ return { packages, displayPackages, deps, error: err, exitCode: 1 };
425
+ }
426
+ if (packages.length === 0) {
427
+ return { packages, displayPackages, deps, error: 'pip install: missing package name', exitCode: 2 };
428
+ }
429
+ return { packages, displayPackages, deps, exitCode: 0 };
430
+ }
431
+ function addRequirementsFile(reqPath, baseDir, vfs, packages, displayPackages, seen, depth) {
432
+ if (depth > 8)
433
+ return 'requirements nesting exceeded 8 files';
434
+ const abs = resolveVfsPath(reqPath, baseDir);
435
+ if (seen.has(abs))
436
+ return null;
437
+ seen.add(abs);
438
+ if (!vfs.exists(abs))
439
+ return `requirements file not found: ${reqPath}`;
440
+ let text = '';
441
+ try {
442
+ text = new TextDecoder('utf-8').decode(vfs.readFile(abs));
443
+ }
444
+ catch (e) {
445
+ return `cannot read requirements file ${reqPath}: ${e?.message || e}`;
446
+ }
447
+ const nextBaseDir = parentVfsPath(abs);
448
+ for (const line of logicalRequirementLines(text)) {
449
+ const trimmed = trimRequirementComment(line);
450
+ if (!trimmed)
451
+ continue;
452
+ const tokens = splitRequirementArgs(trimmed);
453
+ if (tokens.length === 0)
454
+ continue;
455
+ const head = tokens[0];
456
+ if (head === '-r' || head === '--requirement') {
457
+ if (!tokens[1])
458
+ return `${reqPath}: ${head}: missing requirements file`;
459
+ const err = addRequirementsFile(tokens[1], nextBaseDir, vfs, packages, displayPackages, seen, depth + 1);
460
+ if (err)
461
+ return err;
462
+ continue;
463
+ }
464
+ if (head.startsWith('--requirement=')) {
465
+ const err = addRequirementsFile(head.slice('--requirement='.length), nextBaseDir, vfs, packages, displayPackages, seen, depth + 1);
466
+ if (err)
467
+ return err;
468
+ continue;
469
+ }
470
+ if (head === '-c' || head === '--constraint' || head.startsWith('--constraint=')) {
471
+ return `${reqPath}: constraints are not supported by Nimbus pip yet`;
472
+ }
473
+ if (head.startsWith('--hash='))
474
+ continue;
475
+ if (head.startsWith('-'))
476
+ continue;
477
+ const normalized = normalizePipInstallSpec(head, nextBaseDir, vfs);
478
+ if ('error' in normalized)
479
+ return normalized.error;
480
+ packages.push(normalized.installSpec);
481
+ displayPackages.push(normalized.displaySpec);
482
+ }
483
+ return null;
484
+ }
485
+ function logicalRequirementLines(text) {
486
+ const out = [];
487
+ let current = '';
488
+ for (const rawLine of text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n')) {
489
+ const line = rawLine.trimEnd();
490
+ if (line.endsWith('\\')) {
491
+ current += line.slice(0, -1) + ' ';
492
+ continue;
493
+ }
494
+ out.push(current + line);
495
+ current = '';
496
+ }
497
+ if (current)
498
+ out.push(current);
499
+ return out;
500
+ }
501
+ function trimRequirementComment(line) {
502
+ let quote = '';
503
+ for (let i = 0; i < line.length; i++) {
504
+ const ch = line[i];
505
+ if (quote) {
506
+ if (ch === quote)
507
+ quote = '';
508
+ if (ch === '\\')
509
+ i++;
510
+ continue;
511
+ }
512
+ if (ch === '"' || ch === "'") {
513
+ quote = ch;
514
+ continue;
515
+ }
516
+ if (ch === '#') {
517
+ const prev = i === 0 ? ' ' : line[i - 1];
518
+ if (i === 0 || prev === ' ' || prev === '\t')
519
+ return line.slice(0, i).trim();
520
+ }
521
+ }
522
+ return line.trim();
523
+ }
524
+ function splitRequirementArgs(line) {
525
+ const out = [];
526
+ let cur = '';
527
+ let quote = '';
528
+ for (let i = 0; i < line.length; i++) {
529
+ const ch = line[i];
530
+ if (quote) {
531
+ if (ch === quote) {
532
+ quote = '';
533
+ }
534
+ else if (ch === '\\' && i + 1 < line.length) {
535
+ cur += line[++i];
536
+ }
537
+ else {
538
+ cur += ch;
539
+ }
540
+ continue;
541
+ }
542
+ if (ch === '"' || ch === "'") {
543
+ quote = ch;
544
+ continue;
545
+ }
546
+ if (ch === ' ' || ch === '\t') {
547
+ if (cur) {
548
+ out.push(cur);
549
+ cur = '';
550
+ }
551
+ continue;
552
+ }
553
+ cur += ch;
554
+ }
555
+ if (cur)
556
+ out.push(cur);
557
+ return out;
558
+ }
559
+ function normalizePipInstallSpec(rawSpec, baseDir, vfs) {
560
+ if (!rawSpec)
561
+ return { error: 'empty requirement' };
562
+ let spec = rawSpec;
563
+ if (spec.startsWith('file://'))
564
+ spec = spec.slice('file://'.length);
565
+ const looksLikePath = spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../') || spec.endsWith('.whl');
566
+ if (!looksLikePath)
567
+ return { installSpec: spec, displaySpec: spec };
568
+ const abs = resolveVfsPath(spec, baseDir);
569
+ if (!vfs.exists(abs))
570
+ return { error: `local wheel not found: ${rawSpec}` };
571
+ const fileName = abs.slice(abs.lastIndexOf('/') + 1);
572
+ if (!fileName.endsWith('.whl')) {
573
+ return { error: `local installs currently require a .whl file: ${rawSpec}` };
574
+ }
575
+ const wheelError = validatePyodideWheelFileName(fileName);
576
+ if (wheelError)
577
+ return { error: wheelError };
578
+ return {
579
+ installSpec: `emfs:/${abs}`,
580
+ displaySpec: fileName.slice(0, -'.whl'.length),
581
+ };
582
+ }
583
+ function validatePyodideWheelFileName(fileName) {
584
+ const stem = fileName.endsWith('.whl') ? fileName.slice(0, -4) : fileName;
585
+ const parts = stem.split('-');
586
+ if (parts.length < 5)
587
+ return `invalid wheel filename: ${fileName}`;
588
+ const pythonTag = parts[parts.length - 3].toLowerCase();
589
+ const abiTag = parts[parts.length - 2].toLowerCase();
590
+ const platformTag = parts[parts.length - 1].toLowerCase();
591
+ if (platformTag === 'any' && abiTag === 'none')
592
+ return null;
593
+ if (platformTag.includes('manylinux') ||
594
+ platformTag.includes('musllinux') ||
595
+ platformTag.includes('linux') ||
596
+ platformTag.includes('macosx') ||
597
+ platformTag.includes('win')) {
598
+ return `native Linux wheel '${fileName}' cannot run in Nimbus; install a pure wheel or a package with a pure Python fallback`;
599
+ }
600
+ if (platformTag.includes('emscripten') || platformTag.includes('wasm32')) {
601
+ return `Pyodide/Emscripten extension wheel '${fileName}' needs precompiled runtime-module support; install a pure wheel or a package with a pure Python fallback`;
602
+ }
603
+ if (pythonTag.startsWith('py') && platformTag === 'any')
604
+ return null;
605
+ return `wheel '${fileName}' targets unsupported platform '${platformTag}'; Nimbus pip supports pure Python wheels and packages with pure Python fallbacks`;
606
+ }
607
+ function isIgnoredPipInstallFlag(arg) {
608
+ return IGNORED_PIP_INSTALL_FLAGS.has(arg);
609
+ }
610
+ function pipFlagTakesValue(arg) {
611
+ if (arg.includes('='))
612
+ return false;
613
+ return PIP_INSTALL_FLAGS_WITH_VALUE.has(arg);
614
+ }
615
+ function hasLeadingCliFlag(argv, flags) {
616
+ for (let i = 0; i < argv.length; i++) {
617
+ const arg = argv[i];
618
+ if (flags.has(arg))
619
+ return true;
620
+ if (arg === '--')
621
+ return false;
622
+ if (arg === '-c' || arg === '-m' || arg === '-')
623
+ return false;
624
+ if (!arg.startsWith('-'))
625
+ return false;
626
+ }
627
+ return false;
628
+ }
629
+ function parsePythonHttpServer(argv, cwd) {
630
+ if (argv[0] !== '-m' || argv[1] !== 'http.server')
631
+ return null;
632
+ let port = 8000;
633
+ let directory = cwd;
634
+ for (let i = 2; i < argv.length; i++) {
635
+ const arg = argv[i];
636
+ if (arg === '--directory') {
637
+ directory = argv[i + 1] || directory;
638
+ i++;
639
+ continue;
640
+ }
641
+ if (arg.startsWith('--directory=')) {
642
+ directory = arg.slice('--directory='.length) || directory;
643
+ continue;
644
+ }
645
+ if (arg === '--bind' || arg === '-b') {
646
+ i++;
647
+ continue;
648
+ }
649
+ if (!arg.startsWith('-') && /^\d+$/.test(arg)) {
650
+ port = parsePort(arg, port);
651
+ }
652
+ }
653
+ return { port, root: resolveVfsPath(directory, cwd) };
654
+ }
157
655
  function parsePythonArgv(argv) {
158
656
  // Walk argv left-to-right looking for the first non-flag token OR
159
657
  // the -c / -m mode-switches. Python's CLI is more involved (-O,
@@ -218,86 +716,14 @@ function parsePythonArgv(argv) {
218
716
  return { mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [], exitCode: 2,
219
717
  error: "REPL not supported in v1. Use 'python -c \"code\"' or 'python script.py'." };
220
718
  }
221
- function resolveVfsPath(rel, cwd) {
222
- const cwdN = cwd.replace(/^\/+/, '').replace(/\/+$/, '');
223
- if (rel.startsWith('/'))
224
- return rel.replace(/^\/+/, '');
225
- if (rel === '.')
226
- return cwdN;
227
- return `${cwdN}/${rel}`;
228
- }
229
- function uint8ToBase64(u8) {
230
- const CHUNK = 0x8000;
231
- let s = '';
232
- for (let i = 0; i < u8.length; i += CHUNK) {
233
- s += String.fromCharCode.apply(null, Array.from(u8.subarray(i, Math.min(i + CHUNK, u8.length))));
234
- }
235
- return btoa(s);
719
+ function buildPythonRuntimeAssets(args) {
720
+ const files = readPyodideRuntimeFiles(args);
721
+ return {
722
+ asmWasmBytes: files.asmWasmBytes,
723
+ preamble: buildPyodidePreamble(files.asmJsSrc, files.stdlibB64, files.lockfileText),
724
+ };
236
725
  }
237
- async function dispatchPythonFacet(facetMgr, args) {
238
- const toAB = (u8) => u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
239
- // pyodide.asm.js is INLINED into the preamble (1 MiB text source).
240
- // Workerd's CSP allows wasm code-gen AND JS top-level evaluation at
241
- // module-load time, but BLOCKS `new Function(src)` at request time.
242
- // The asm.js source declares `var _createPyodideModule = ...` at
243
- // module-top; spliced into the preamble it becomes a module-scope
244
- // binding we expose via `globalThis._createPyodideModule = ...`
245
- // right after.
246
- //
247
- // stdlib.zip stays in the context (base64) — it's data not code,
248
- // and the context blob is evaluated at module-load too.
249
- let asmJsSrc = new TextDecoder('utf-8').decode(args.asmJsBytes);
250
- // ── Source-patch the asm.js's "Cannot determine runtime environment" branch ──
251
- //
252
- // Even with our globalThis.process / WorkerGlobalScope / self / location
253
- // shims in place, the asm.js's pyodide_js_init() IIFE's env-detect (via
254
- // bn → vn) somehow lands d.IN_BROWSER_WEB_WORKER = false at request time
255
- // on workerd. Our supervisor-side mirror of the same logic returns
256
- // IN_BROWSER_WEB_WORKER = true with the same shims; the discrepancy
257
- // remains unidentified after P8-P17 of diagnostic instrumentation.
258
- //
259
- // Pragmatic v1 fix: source-patch the if-chain to ALWAYS take the
260
- // IN_BROWSER_WEB_WORKER branch (regardless of `d`). The branch's body
261
- // assigns `Fe` to a globalThis.importScripts-based loadScript helper.
262
- // We don't actually call Fe (Pyodide only invokes loadScript when
263
- // dynamic-loading a wasm sibling; our instantiateWasm override pre-
264
- // empts that). So the patch is functionally a no-op other than making
265
- // the throw unreachable.
266
- //
267
- // The patch target string is a literal in the minified asm.js — stable
268
- // across rebuilds because it's a unique identifier path.
269
- const PATCH_NEEDLE = 'else throw new Error("Cannot determine runtime environment")';
270
- const PATCH_REPLACE = '/* nimbus-patch: was: ' + PATCH_NEEDLE + ' */';
271
- if (asmJsSrc.includes(PATCH_NEEDLE)) {
272
- asmJsSrc = asmJsSrc.replace(PATCH_NEEDLE, PATCH_REPLACE);
273
- }
274
- // Also patch the if-chain head so we ALWAYS set Fe (force-take the
275
- // first branch — IN_BROWSER_MAIN_THREAD's loadScript path uses
276
- // `await import(t)` which works in workerd at module-load time).
277
- // The previous patch alone is insufficient because if NO branch matches,
278
- // Fe stays undefined → calling it would TypeError later.
279
- const HEAD_NEEDLE = 'if(d.IN_BROWSER_MAIN_THREAD)';
280
- const HEAD_REPLACE = 'if(true||d.IN_BROWSER_MAIN_THREAD)';
281
- if (asmJsSrc.includes(HEAD_NEEDLE)) {
282
- asmJsSrc = asmJsSrc.replace(HEAD_NEEDLE, HEAD_REPLACE);
283
- }
284
- const stdlibB64 = uint8ToBase64(args.stdlibBytes);
285
- // Build the preamble with stdlib bytes spliced in. The preamble runs
286
- // the entire Pyodide bootstrap at child-facet module-init time —
287
- // where workerd's CSP permits `new WebAssembly.Module(rawBytes)` (the
288
- // convertJsFunctionToWasm code path which is the v1 hang root).
289
- // Per-call __pyodideRun just executes runPython on the cached
290
- // Pyodide instance.
291
- const preamble = buildPyodidePreamble(asmJsSrc, stdlibB64);
292
- const { NimbusLoaderPool } = await import('../loaders/loader-pool.js');
293
- const env = facetMgr.env;
294
- const ctx = facetMgr.ctx;
295
- const pool = new NimbusLoaderPool(env, ctx, {
296
- tag: 'python-runner',
297
- concurrency: 1,
298
- omitSupervisor: true,
299
- preamble,
300
- });
726
+ async function dispatchPythonFacet(runtime, args) {
301
727
  // v2 simplification: stdlibB64 and asmWasmMod are now embedded in the
302
728
  // preamble (stdlib via base64 splice; wasm via __NIMBUS_WASM table set
303
729
  // by the loader-pool). The facet fn only needs the per-call inputs.
@@ -312,18 +738,23 @@ async function dispatchPythonFacet(facetMgr, args) {
312
738
  pyArgv: inArgs.pyArgv,
313
739
  userEnv: inArgs.userEnv,
314
740
  progName: inArgs.progName,
741
+ cwd: inArgs.cwd,
742
+ fsSnapshot: inArgs.fsSnapshot,
743
+ asyncRun: !!inArgs.asyncRun,
744
+ pyodidePackages: inArgs.pyodidePackages || [],
315
745
  });
316
746
  };
317
747
  try {
318
- const result = await pool.submit(facetFn, {
748
+ const result = await runtime.pool.submit(facetFn, {
319
749
  userCode: args.userCode,
320
750
  pyArgv: args.pyArgv,
321
751
  userEnv: args.userEnv,
322
752
  progName: args.progName,
753
+ cwd: args.cwd,
754
+ fsSnapshot: args.fsSnapshot,
755
+ asyncRun: !!args.asyncRun,
756
+ pyodidePackages: args.pyodidePackages || [],
323
757
  }, {
324
- wasmModules: {
325
- 'pyodide.asm.wasm': toAB(args.asmWasmBytes),
326
- },
327
758
  timeoutMs: 300_000,
328
759
  });
329
760
  return {
@@ -331,6 +762,7 @@ async function dispatchPythonFacet(facetMgr, args) {
331
762
  stdout: result.stdout || '',
332
763
  stderr: result.stderr || '',
333
764
  error: result.error,
765
+ fsDiff: result.fsDiff,
334
766
  };
335
767
  }
336
768
  catch (e) {
@@ -342,9 +774,219 @@ async function dispatchPythonFacet(facetMgr, args) {
342
774
  };
343
775
  }
344
776
  }
777
+ function shouldRunPythonAsSocketProcess(argv, parsed, pipMode) {
778
+ if (pipMode)
779
+ return false;
780
+ if (parsed.mode === 'script')
781
+ return true;
782
+ if (argv[0] === '-m' && argv[1] && argv[1] !== 'pip')
783
+ return true;
784
+ return false;
785
+ }
786
+ function formatPythonCommand(binName, argv) {
787
+ return [binName, ...argv].map((part) => {
788
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(part))
789
+ return part;
790
+ return JSON.stringify(part);
791
+ }).join(' ');
792
+ }
793
+ async function spawnPythonSocketProcess(facetMgr, assetPaths, args, command) {
794
+ const toAB = (u8) => u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
795
+ const assets = buildPythonRuntimeAssets(assetPaths);
796
+ const workerCode = buildPythonSocketProcessWorker(assets.preamble);
797
+ const spawned = facetMgr.spawnWorker(workerCode, command, args.cwd, {
798
+ compatibilityFlags: ['nodejs_compat'],
799
+ modules: {
800
+ 'pyodide.asm.wasm': { wasm: toAB(assets.asmWasmBytes) },
801
+ },
802
+ });
803
+ const bootResponse = await spawned.facetStub.fetch(new Request('http://nimbus.internal/__nimbus_start', {
804
+ method: 'POST',
805
+ headers: { 'Content-Type': 'application/json' },
806
+ body: JSON.stringify({
807
+ userCode: args.userCode,
808
+ pyArgv: args.pyArgv,
809
+ userEnv: args.userEnv,
810
+ progName: args.progName,
811
+ cwd: args.cwd,
812
+ fsSnapshot: args.fsSnapshot,
813
+ }),
814
+ }));
815
+ const boot = await bootResponse.json().catch(() => null);
816
+ if (!bootResponse.ok || !boot) {
817
+ facetMgr.finishProcess(spawned.pid, 1, 'python process boot failed');
818
+ return {
819
+ exitCode: 1,
820
+ stdout: '',
821
+ stderr: `python process boot failed: HTTP ${bootResponse.status}\n`,
822
+ };
823
+ }
824
+ if (boot.state === 'listening' && boot.port > 0) {
825
+ facetMgr.registerPort(spawned.pid, Number(boot.port), spawned.facetStub);
826
+ return {
827
+ exitCode: 0,
828
+ stdout: `${boot.stdout || ''}\x1b[2m[started (long-running): pid=${spawned.pid} cmd="${command}" port=${boot.port}]\x1b[0m\n`,
829
+ stderr: boot.stderr || '',
830
+ spawnedPid: spawned.pid,
831
+ port: Number(boot.port),
832
+ };
833
+ }
834
+ const reservedPorts = facetMgr.attachReservedPorts(spawned.pid, spawned.facetStub);
835
+ if (reservedPorts.length > 0) {
836
+ return {
837
+ exitCode: 0,
838
+ stdout: `${boot.stdout || ''}\x1b[2m[started (long-running): pid=${spawned.pid} cmd="${command}" port=${reservedPorts[0]}]\x1b[0m\n`,
839
+ stderr: boot.stderr || '',
840
+ spawnedPid: spawned.pid,
841
+ port: reservedPorts[0],
842
+ };
843
+ }
844
+ if (boot.state === 'exited') {
845
+ const result = boot.result || {};
846
+ facetMgr.finishProcess(spawned.pid, Number(result.exitCode || 0), result.stderr || 'python process exited');
847
+ return {
848
+ exitCode: Number(result.exitCode || 0),
849
+ stdout: result.stdout || '',
850
+ stderr: result.stderr || result.error || '',
851
+ fsDiff: result.fsDiff,
852
+ };
853
+ }
854
+ return {
855
+ exitCode: 0,
856
+ stdout: `${boot.stdout || ''}\x1b[2m[started (long-running): pid=${spawned.pid} cmd="${command}"]\x1b[0m\n`,
857
+ stderr: boot.stderr || '',
858
+ spawnedPid: spawned.pid,
859
+ };
860
+ }
861
+ function buildPythonSocketProcessWorker(preamble) {
862
+ return [
863
+ 'import { WorkerEntrypoint } from "cloudflare:workers";',
864
+ "import __NIMBUS_WASM_pyodide_asm_wasm from './pyodide.asm.wasm';",
865
+ 'globalThis.__NIMBUS_WASM = globalThis.__NIMBUS_WASM || {};',
866
+ "globalThis.__NIMBUS_WASM['pyodide.asm.wasm'] = __NIMBUS_WASM_pyodide_asm_wasm;",
867
+ '',
868
+ VIRTUAL_SOCKET_KERNEL_SRC,
869
+ '',
870
+ 'globalThis.__nimbusVirtualPortRegistrationPromises = globalThis.__nimbusVirtualPortRegistrationPromises || [];',
871
+ 'globalThis.__nimbusVirtualSocketDidListen = function __nimbusVirtualSocketDidListen(port) {',
872
+ ' const supervisor = globalThis.__nimbusPythonSupervisor;',
873
+ ' if (!supervisor || typeof supervisor.registerPort !== "function") return;',
874
+ ' try {',
875
+ ' const p = supervisor.registerPort(Number(port)).catch((e) => {',
876
+ ' const msg = e && e.message ? e.message : String(e);',
877
+ ' (globalThis.__nimbusPyStderr || (globalThis.__nimbusPyStderr = [])).push("[python-runner] port registration failed: " + msg + "\\n");',
878
+ ' });',
879
+ ' globalThis.__nimbusVirtualPortRegistrationPromises.push(p);',
880
+ ' } catch (e) {',
881
+ ' const msg = e && e.message ? e.message : String(e);',
882
+ ' (globalThis.__nimbusPyStderr || (globalThis.__nimbusPyStderr = [])).push("[python-runner] port registration failed: " + msg + "\\n");',
883
+ ' }',
884
+ '};',
885
+ '',
886
+ preamble,
887
+ '',
888
+ 'globalThis.__nimbusPythonSocketserverQueue = globalThis.__nimbusPythonSocketserverQueue || Promise.resolve();',
889
+ 'function __nimbusRunPythonSocketserverCall(fnName, port) {',
890
+ ' const run = async () => {',
891
+ ' const pyodide = globalThis.__nimbusPyodideInstance;',
892
+ ' if (!pyodide || typeof pyodide.runPythonAsync !== "function") return false;',
893
+ ' const n = Number(port);',
894
+ ' if (!Number.isInteger(n) || n <= 0 || n >= 65536) return false;',
895
+ ' try {',
896
+ ' await pyodide.runPythonAsync(fnName + "(" + n + ")");',
897
+ ' return true;',
898
+ ' } catch (e) {',
899
+ ' const msg = e && e.message ? e.message : String(e);',
900
+ ' (globalThis.__nimbusPyStderr || (globalThis.__nimbusPyStderr = [])).push("[python-runner] socketserver call failed: " + msg + "\\n");',
901
+ ' return false;',
902
+ ' }',
903
+ ' };',
904
+ ' const task = globalThis.__nimbusPythonSocketserverQueue.then(run, run);',
905
+ ' globalThis.__nimbusPythonSocketserverQueue = task.then(() => {}, () => {});',
906
+ ' return task;',
907
+ '}',
908
+ 'globalThis.__nimbusVirtualSocketEnsureListener = function __nimbusVirtualSocketEnsureListener(port) {',
909
+ ' return __nimbusRunPythonSocketserverCall("_nimbus_ensure_socketserver_listener", port);',
910
+ '};',
911
+ 'globalThis.__nimbusVirtualSocketRequestQueued = function __nimbusVirtualSocketRequestQueued(port) {',
912
+ ' return __nimbusRunPythonSocketserverCall("_nimbus_handle_socketserver_request", port);',
913
+ '};',
914
+ '',
915
+ 'async function __nimbusStartPythonProcess(args) {',
916
+ ' if (!globalThis.__nimbusPythonProcessPromise) {',
917
+ ' const stdoutStart = (globalThis.__nimbusPyStdout || []).length;',
918
+ ' const stderrStart = (globalThis.__nimbusPyStderr || []).length;',
919
+ ' globalThis.__nimbusPythonProcessOutputStart = { stdoutStart, stderrStart };',
920
+ ' globalThis.__nimbusPythonProcessPromise = (async () => {',
921
+ ' const result = await globalThis.__pyodideRun({',
922
+ ' userCode: args.userCode,',
923
+ ' pyArgv: args.pyArgv || [],',
924
+ ' userEnv: args.userEnv || {},',
925
+ ' progName: args.progName || "python",',
926
+ ' cwd: args.cwd || "/home/user",',
927
+ ' fsSnapshot: args.fsSnapshot,',
928
+ ' asyncRun: true,',
929
+ ' pyodidePackages: [],',
930
+ ' });',
931
+ ' globalThis.__nimbusPythonProcessResult = result;',
932
+ ' return result;',
933
+ ' })();',
934
+ ' }',
935
+ ' const started = globalThis.__nimbusPythonProcessOutputStart || { stdoutStart: 0, stderrStart: 0 };',
936
+ ' const listen = globalThis.__nimbusVirtualSockets.waitForListen(10_000).then((port) => ({ state: port ? "listening" : "pending", port }));',
937
+ ' const exit = globalThis.__nimbusPythonProcessPromise.then((result) => ({ state: "exited", result }));',
938
+ ' const first = await Promise.race([listen, exit]);',
939
+ ' const registrations = globalThis.__nimbusVirtualPortRegistrationPromises || [];',
940
+ ' if (registrations.length > 0) await Promise.allSettled(registrations.splice(0));',
941
+ ' const stdout = (globalThis.__nimbusPyStdout || []).slice(started.stdoutStart).join("");',
942
+ ' const stderr = (globalThis.__nimbusPyStderr || []).slice(started.stderrStart).join("");',
943
+ ' if (first.state === "listening") return { state: "listening", port: first.port, stdout, stderr };',
944
+ ' if (first.state === "exited") return { state: "exited", result: first.result, stdout, stderr };',
945
+ ' const currentPort = globalThis.__nimbusVirtualSockets.firstListeningPort();',
946
+ ' if (currentPort) return { state: "listening", port: currentPort, stdout, stderr };',
947
+ ' return { state: "running", stdout, stderr };',
948
+ '}',
949
+ '',
950
+ 'export default class NimbusPythonProcess extends WorkerEntrypoint {',
951
+ ' async fetch(request) {',
952
+ ' globalThis.__nimbusPythonSupervisor = this.env?.SUPERVISOR;',
953
+ ' const url = new URL(request.url);',
954
+ ' if (url.pathname === "/__nimbus_start") {',
955
+ ' const args = await request.json();',
956
+ ' const boot = await __nimbusStartPythonProcess(args);',
957
+ ' return Response.json(boot);',
958
+ ' }',
959
+ ' return this.handleHttpRequest(request);',
960
+ ' }',
961
+ ' async handleHttpRequest(request) {',
962
+ ' const hinted = Number(request.headers.get("X-Nimbus-Port") || 0);',
963
+ ' const port = hinted || Array.from(globalThis.__nimbusVirtualSockets.listeners.keys())[0];',
964
+ ' if (!port) return new Response("Nimbus Python process has no listening virtual socket", { status: 502 });',
965
+ ' return globalThis.__nimbusVirtualSockets.handleHttpRequest(port, request);',
966
+ ' }',
967
+ '}',
968
+ ].join('\n');
969
+ }
970
+ async function createPythonFacetRuntime(facetMgr, args) {
971
+ const toAB = (u8) => u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
972
+ const assets = buildPythonRuntimeAssets(args);
973
+ const { NimbusLoaderPool } = await import('../loaders/loader-pool.js');
974
+ const env = facetMgr.env;
975
+ const ctx = facetMgr.ctx;
976
+ const pool = new NimbusLoaderPool(env, ctx, {
977
+ tag: 'python-runner',
978
+ concurrency: 1,
979
+ omitSupervisor: true,
980
+ preamble: assets.preamble,
981
+ wasmModules: {
982
+ 'pyodide.asm.wasm': toAB(assets.asmWasmBytes),
983
+ },
984
+ });
985
+ return { pool };
986
+ }
345
987
  /**
346
- * Compose the per-call preamble by splicing the pyodide.asm.js source
347
- * verbatim ahead of the __pyodideRun helper. Workerd compiles this
988
+ * Compose the per-call preamble by splicing the workerd-adapted
989
+ * pyodide.asm.js source ahead of the __pyodideRun helper. Workerd compiles this
348
990
  * blob as JS at module-load time (where `var` declarations + globals
349
991
  * assignment are allowed), then the asm.js's `var _createPyodideModule`
350
992
  * is hoisted onto globalThis.
@@ -353,7 +995,7 @@ async function dispatchPythonFacet(facetMgr, args) {
353
995
  // reuse the canonical preamble verbatim. Additive change only —
354
996
  // internal callers are unchanged. The `export` keyword is the sole
355
997
  // modification to python-runner.ts in the REPL-W1 wave.
356
- export function buildPyodidePreamble(asmJsSrc, stdlibB64) {
998
+ export function buildPyodidePreamble(asmJsSrc, stdlibB64, lockfileContents = '{"packages":{}}') {
357
999
  return [
358
1000
  '// ── Pre-asm.js environment shims ───────────────────────────────',
359
1001
  '// Pyodide.asm.js detects its environment via heuristics. In',
@@ -440,7 +1082,7 @@ export function buildPyodidePreamble(asmJsSrc, stdlibB64) {
440
1082
  'globalThis.WorkerGlobalScope = __nimbusOrigWGS;',
441
1083
  '// ── END: pyodide.asm.js inline ──────────────────────────────────',
442
1084
  '',
443
- buildPreambleTail(stdlibB64),
1085
+ buildPreambleTail(stdlibB64, lockfileContents),
444
1086
  ].join('\n');
445
1087
  }
446
1088
  // ── Facet preamble ───────────────────────────────────────────────────
@@ -511,7 +1153,7 @@ export function buildPyodidePreamble(asmJsSrc, stdlibB64) {
511
1153
  // │ - runPython(userCode) → stdout/stderr/exitCode │
512
1154
  // └─────────────────────────────────────────────────────────────┘
513
1155
  //
514
- function buildPreambleTail(stdlibB64) {
1156
+ function buildPreambleTail(stdlibB64, lockfileContents) {
515
1157
  return `
516
1158
  // ── BEGIN: python-runner preamble tail (Pyodide 0.29.4, Nimbus v2) ──
517
1159
 
@@ -519,6 +1161,9 @@ function buildPreambleTail(stdlibB64) {
519
1161
  // base64 text — decoded once at module-init. Same content as
520
1162
  // share/pyodide/python_stdlib.zip in the runtime cache.
521
1163
  const __NIMBUS_STDLIB_B64 = ${JSON.stringify(stdlibB64)};
1164
+ const __NIMBUS_LOCKFILE_CONTENTS = ${JSON.stringify(lockfileContents)};
1165
+ const __NIMBUS_PERSISTENT_SITE_PACKAGES = '/home/user/.nimbus-python/site-packages';
1166
+ const __NIMBUS_PYTHON_SOCKET_SHIM = ${JSON.stringify(PYTHON_SOCKET_SHIM)};
522
1167
 
523
1168
  // Decode base64 → Uint8Array. Run at module-init time (synchronous).
524
1169
  const __nimbusStdlibBytes = (function decode(b64) {
@@ -558,6 +1203,134 @@ try {
558
1203
  globalThis.__nimbusPyStdout = globalThis.__nimbusPyStdout || [];
559
1204
  globalThis.__nimbusPyStderr = globalThis.__nimbusPyStderr || [];
560
1205
 
1206
+ if (!globalThis.__nimbusPythonFetchStripsIntegrity && typeof globalThis.fetch === 'function') {
1207
+ const __nimbusOrigFetch = globalThis.fetch.bind(globalThis);
1208
+ globalThis.fetch = function nimbusPythonFetch(input, init) {
1209
+ if (init && typeof init === 'object' && init.integrity) {
1210
+ const clean = Object.assign({}, init);
1211
+ delete clean.integrity;
1212
+ return __nimbusOrigFetch(input, clean);
1213
+ }
1214
+ return __nimbusOrigFetch(input, init);
1215
+ };
1216
+ globalThis.__nimbusPythonFetchStripsIntegrity = true;
1217
+ }
1218
+
1219
+ function __nimbusB64ToBytes(b64) {
1220
+ const bin = atob(b64);
1221
+ const out = new Uint8Array(bin.length);
1222
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
1223
+ return out;
1224
+ }
1225
+
1226
+ function __nimbusBytesToB64(bytes) {
1227
+ let s = '';
1228
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
1229
+ return btoa(s);
1230
+ }
1231
+
1232
+ function __nimbusInstallPythonSocketModules(pyodide) {
1233
+ if (!globalThis.__nimbusVirtualSockets || !pyodide || globalThis.__nimbusPythonSocketsInstalled) return;
1234
+ const kernel = globalThis.__nimbusVirtualSockets;
1235
+ try {
1236
+ pyodide.registerJsModule('nimbus_sockets', {
1237
+ listen: (port) => kernel.listen(port),
1238
+ close_listener: (port) => kernel.closeListener(port),
1239
+ accept: async (port) => await kernel.accept(port),
1240
+ accept_now: (port) => kernel.acceptNow(port),
1241
+ recv: (id, maxBytes) => kernel.recv(id, maxBytes),
1242
+ send: (id, bytes) => kernel.send(id, bytes),
1243
+ close: (id) => kernel.close(id),
1244
+ pending: (port) => kernel.pending(port),
1245
+ sleep: async (ms) => await new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))),
1246
+ wait_readable: async (ports, timeoutSeconds) => await kernel.waitReadable(Array.from(ports || []), timeoutSeconds),
1247
+ });
1248
+ } catch {}
1249
+ try {
1250
+ pyodide.runPython(__NIMBUS_PYTHON_SOCKET_SHIM);
1251
+ globalThis.__nimbusPythonSocketsInstalled = true;
1252
+ } catch (e) {
1253
+ globalThis.__nimbusPyStderr.push('[python-runner] virtual socket shim install failed: ' + (e && e.message) + '\\n');
1254
+ }
1255
+ }
1256
+
1257
+ function __nimbusNormPath(path) {
1258
+ const clean = String(path || '').replace(/^\\/+/, '').replace(/\\/+$/, '');
1259
+ return clean ? '/' + clean : '/';
1260
+ }
1261
+
1262
+ function __nimbusInstallFsSnapshot(M, snapshot) {
1263
+ if (!snapshot || !snapshot.root) return;
1264
+ const currentFiles = new Set(Object.keys(snapshot.files || {}));
1265
+ const previous = globalThis.__nimbusMountedPyFiles || new Set();
1266
+ for (const prev of Array.from(previous)) {
1267
+ if (currentFiles.has(prev)) continue;
1268
+ try {
1269
+ const abs = __nimbusNormPath(prev);
1270
+ if (M.FS.analyzePath(abs).exists) M.FS.unlink(abs);
1271
+ } catch {}
1272
+ }
1273
+ const dirs = Array.from(new Set(snapshot.dirs || [])).sort((a, b) => a.length - b.length);
1274
+ for (const dir of dirs) {
1275
+ try { M.FS.mkdirTree(__nimbusNormPath(dir)); } catch {}
1276
+ }
1277
+ for (const [path, b64] of Object.entries(snapshot.files || {})) {
1278
+ const abs = __nimbusNormPath(path);
1279
+ try {
1280
+ const parent = abs.slice(0, abs.lastIndexOf('/')) || '/';
1281
+ M.FS.mkdirTree(parent);
1282
+ M.FS.writeFile(abs, __nimbusB64ToBytes(b64));
1283
+ } catch (e) {
1284
+ globalThis.__nimbusPyStderr.push('[python-runner] VFS mount failed for ' + path + ': ' + (e && e.message) + '\\n');
1285
+ }
1286
+ }
1287
+ globalThis.__nimbusMountedPyFiles = currentFiles;
1288
+ }
1289
+
1290
+ function __nimbusWalkPyFs(M, absDir, out) {
1291
+ let entries;
1292
+ try { entries = M.FS.readdir(absDir); } catch { return; }
1293
+ for (const name of entries) {
1294
+ if (name === '.' || name === '..') continue;
1295
+ if (name === '.nimbus' || name === 'node_modules' || name === '.cache' || name === '.npm') continue;
1296
+ const abs = absDir === '/' ? '/' + name : absDir + '/' + name;
1297
+ const rel = abs.replace(/^\\/+/, '');
1298
+ let stat;
1299
+ try { stat = M.FS.stat(abs); } catch { continue; }
1300
+ if (M.FS.isDir(stat.mode)) {
1301
+ out.dirs.push(rel);
1302
+ __nimbusWalkPyFs(M, abs, out);
1303
+ continue;
1304
+ }
1305
+ if (M.FS.isFile(stat.mode)) {
1306
+ try { out.files[rel] = __nimbusBytesToB64(M.FS.readFile(abs)); } catch {}
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ function __nimbusSnapshotPyDiff(M, before) {
1312
+ if (!before || !before.root) return null;
1313
+ const roots = Array.from(new Set(((before.roots && before.roots.length) ? before.roots : [before.root]).filter(Boolean)));
1314
+ const after = { dirs: roots.slice(), files: {} };
1315
+ for (const root of roots) {
1316
+ __nimbusWalkPyFs(M, __nimbusNormPath(root), after);
1317
+ }
1318
+ const beforeDirs = new Set(before.dirs || []);
1319
+ const afterDirs = new Set(after.dirs || []);
1320
+ const dirsCreated = after.dirs.filter((path) => !beforeDirs.has(path));
1321
+ const dirsDeleted = (before.dirs || []).filter((path) => !afterDirs.has(path)).sort((a, b) => b.length - a.length);
1322
+ const filesWritten = {};
1323
+ const filesDeleted = [];
1324
+ const beforeFiles = before.files || {};
1325
+ for (const [path, b64] of Object.entries(after.files)) {
1326
+ if (beforeFiles[path] !== b64) filesWritten[path] = b64;
1327
+ }
1328
+ for (const path of Object.keys(beforeFiles)) {
1329
+ if (!(path in after.files)) filesDeleted.push(path);
1330
+ }
1331
+ return { filesWritten, filesDeleted, dirsCreated, dirsDeleted };
1332
+ }
1333
+
561
1334
  // Bootstrap gate release fn — populated by gateRuntimeInit preRun hook
562
1335
  // during _createPyodideModule's synchronous preRun pass.
563
1336
  globalThis.__nimbusReleaseGate = null;
@@ -597,19 +1370,23 @@ globalThis.__pyodideBootstrap = (async function nimbusBootstrap() {
597
1370
  return { ok: false, error: '__NIMBUS_WASM missing pyodide.asm.wasm' };
598
1371
  }
599
1372
 
1373
+ const pyodidePackageBaseUrl = 'https://cdn.jsdelivr.net/pyodide/v0.29.4/full/';
1374
+
600
1375
  // Initial Pyodide config — note: args, env, progName come from the
601
1376
  // per-call args at request time. We use sensible defaults here so
602
1377
  // the bootstrap can complete; per-call __pyodideRun overrides via
603
1378
  // M.ENV / pyodide.runPython arguments.
604
1379
  const config = {
605
1380
  indexURL: '/pyodide/',
1381
+ cdnUrl: pyodidePackageBaseUrl,
1382
+ packageBaseUrl: pyodidePackageBaseUrl,
606
1383
  fullStdLib: false,
607
1384
  jsglobals: globalThis,
608
1385
  args: [],
609
1386
  env: { HOME: '/home/pyodide', PYTHONINSPECT: '1' },
610
1387
  packages: [],
611
- lockFileContents: '{"packages":{}}',
612
- packageCacheDir: undefined,
1388
+ lockFileContents: __NIMBUS_LOCKFILE_CONTENTS,
1389
+ packageCacheDir: pyodidePackageBaseUrl,
613
1390
  enableRunUntilComplete: true,
614
1391
  checkAPIVersion: false,
615
1392
  _sysExecutable: 'python',
@@ -630,7 +1407,11 @@ globalThis.__pyodideBootstrap = (async function nimbusBootstrap() {
630
1407
  onExit: function(code) { globalThis.__nimbusExitCode = code | 0; },
631
1408
  thisProgram: config._sysExecutable,
632
1409
  arguments: config.args,
633
- API: { config: config, runtimeEnv: { IN_NODE: false, IN_BROWSER: false, IN_SHELL: false } },
1410
+ API: {
1411
+ config: config,
1412
+ runtimeEnv: { IN_NODE: false, IN_BROWSER: false, IN_SHELL: false },
1413
+ lockFilePromise: Promise.resolve(__NIMBUS_LOCKFILE_CONTENTS),
1414
+ },
634
1415
  locateFile: function(path) { return '/pyodide/' + path; },
635
1416
  instantiateWasm: function(imports, successCallback) {
636
1417
  // Attach sentinel namespace synchronously — Pyodide expects
@@ -777,6 +1558,18 @@ globalThis.__pyodideRun = async function __pyodideRun(args) {
777
1558
  if (args.userEnv) {
778
1559
  try { Object.assign(pyodideMod.ENV, args.userEnv); } catch {}
779
1560
  }
1561
+ try {
1562
+ __nimbusInstallFsSnapshot(pyodideMod, args.fsSnapshot);
1563
+ try {
1564
+ pyodideMod.FS.mkdirTree(__NIMBUS_PERSISTENT_SITE_PACKAGES);
1565
+ pyodideMod.API.sitePackages = __NIMBUS_PERSISTENT_SITE_PACKAGES;
1566
+ } catch {}
1567
+ const cwd = __nimbusNormPath(args.cwd || (args.userEnv && args.userEnv.HOME) || '/home/user');
1568
+ try { pyodideMod.FS.mkdirTree(cwd); } catch {}
1569
+ try { pyodideMod.FS.chdir(cwd); } catch {}
1570
+ } catch (e) {
1571
+ globalThis.__nimbusPyStderr.push('[python-runner] VFS mount failed: ' + (e && e.message) + '\\n');
1572
+ }
780
1573
 
781
1574
  // finalizeBootstrap returns the public Pyodide JS API (the one with
782
1575
  // .runPython, .globals, .registerJsModule). It registers Python-side
@@ -805,15 +1598,44 @@ globalThis.__pyodideRun = async function __pyodideRun(args) {
805
1598
  // overwrite sys.argv at request time via a tiny runPython prelude.
806
1599
  // pyArgv is the user-visible argv (script name first, then args);
807
1600
  // progName is the executable that prefixes errors.
1601
+ const argv = [args.progName].concat((args.pyArgv || []).slice(0));
1602
+ // If pyArgv already includes the script as [0], use it directly.
1603
+ // Convention from the python-runner caller: pyArgv = [progName,
1604
+ // ...args]. We set sys.argv = pyArgv with [0] = progName.
1605
+ const effectiveArgv = (args.pyArgv && args.pyArgv.length > 0) ? args.pyArgv : argv;
1606
+
1607
+ let userGlobals = null;
1608
+ try {
1609
+ const dictCtor = pyodide.globals?.get?.('dict');
1610
+ userGlobals = typeof dictCtor === 'function' ? dictCtor() : null;
1611
+ if (userGlobals && typeof userGlobals.set === 'function') {
1612
+ userGlobals.set('__name__', '__main__');
1613
+ userGlobals.set('__package__', null);
1614
+ const argv0 = effectiveArgv[0];
1615
+ if (argv0 && argv0 !== '-c' && argv0 !== '-') {
1616
+ userGlobals.set('__file__', argv0);
1617
+ }
1618
+ }
1619
+ try { dictCtor?.destroy?.(); } catch {}
1620
+ } catch {
1621
+ userGlobals = null;
1622
+ }
1623
+
1624
+ __nimbusInstallPythonSocketModules(pyodide);
1625
+
808
1626
  try {
809
- const argv = [args.progName].concat((args.pyArgv || []).slice(0));
810
- // If pyArgv already includes the script as [0], use it directly.
811
- // Convention from the python-runner caller: pyArgv = [progName,
812
- // ...args]. We set sys.argv = pyArgv with [0] = progName.
813
- const effectiveArgv = (args.pyArgv && args.pyArgv.length > 0) ? args.pyArgv : argv;
814
1627
  pyodide.runPython(
815
- 'import sys\\nsys.argv = ' +
816
- JSON.stringify(effectiveArgv)
1628
+ 'import os, sys\\n' +
1629
+ 'sys.argv = ' + JSON.stringify(effectiveArgv) + '\\n' +
1630
+ 'cwd = ' + JSON.stringify(args.cwd || '/home/user') + '\\n' +
1631
+ 'site_packages = ' + JSON.stringify('/home/user/.nimbus-python/site-packages') + '\\n' +
1632
+ 'try:\\n os.chdir(cwd)\\nexcept Exception:\\n pass\\n' +
1633
+ 'try:\\n os.makedirs(site_packages, exist_ok=True)\\nexcept Exception:\\n pass\\n' +
1634
+ 'if site_packages not in sys.path:\\n sys.path.insert(0, site_packages)\\n' +
1635
+ 'try:\\n import site as _nimbus_site; _nimbus_site.addsitedir(site_packages)\\nexcept Exception:\\n pass\\n' +
1636
+ 'try:\\n import sysconfig as _nimbus_sysconfig\\n for _scheme in getattr(_nimbus_sysconfig, "_INSTALL_SCHEMES", {}).values():\\n _scheme["purelib"] = site_packages\\n _scheme["platlib"] = site_packages\\nexcept Exception:\\n pass\\n' +
1637
+ 'if "" not in sys.path:\\n sys.path.insert(0, "")\\n',
1638
+ userGlobals ? { globals: userGlobals } : undefined
817
1639
  );
818
1640
  } catch { /* best-effort */ }
819
1641
 
@@ -824,7 +1646,22 @@ globalThis.__pyodideRun = async function __pyodideRun(args) {
824
1646
  let exitCode = 0;
825
1647
  if (args.userCode) {
826
1648
  try {
827
- pyodide.runPython(args.userCode);
1649
+ if (Array.isArray(args.pyodidePackages) && args.pyodidePackages.length > 0) {
1650
+ for (const pkg of args.pyodidePackages) {
1651
+ await pyodide.loadPackage(pkg);
1652
+ }
1653
+ }
1654
+ if (args.asyncRun && typeof pyodide.runPythonAsync === 'function') {
1655
+ await pyodide.runPythonAsync(
1656
+ args.userCode,
1657
+ userGlobals ? { globals: userGlobals } : undefined
1658
+ );
1659
+ } else {
1660
+ pyodide.runPython(
1661
+ args.userCode,
1662
+ userGlobals ? { globals: userGlobals } : undefined
1663
+ );
1664
+ }
828
1665
  // If runPython returned normally, exit code is 0 unless onExit
829
1666
  // was invoked (which can happen if user called sys.exit but
830
1667
  // Pyodide handled it via ExitStatus before throwing).
@@ -856,10 +1693,13 @@ globalThis.__pyodideRun = async function __pyodideRun(args) {
856
1693
  }
857
1694
  }
858
1695
 
1696
+ try { userGlobals?.destroy?.(); } catch {}
1697
+
859
1698
  return {
860
1699
  exitCode: exitCode,
861
1700
  stdout: globalThis.__nimbusPyStdout.slice(stdoutStart).join(''),
862
1701
  stderr: globalThis.__nimbusPyStderr.slice(stderrStart).join(''),
1702
+ fsDiff: __nimbusSnapshotPyDiff(pyodideMod, args.fsSnapshot),
863
1703
  };
864
1704
  };
865
1705