@nimbus-sh/worker 0.2.0 → 0.2.1

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 (63) hide show
  1. package/dist/facets/manager.d.ts +47 -2
  2. package/dist/facets/manager.d.ts.map +1 -1
  3. package/dist/facets/manager.js +139 -15
  4. package/dist/runtime/bash-repl.d.ts +1 -1
  5. package/dist/runtime/bash-repl.d.ts.map +1 -1
  6. package/dist/runtime/bash-repl.js +3 -2
  7. package/dist/runtime/clang-runner.d.ts +1 -1
  8. package/dist/runtime/clang-runner.d.ts.map +1 -1
  9. package/dist/runtime/cpython-resident.d.ts +27 -0
  10. package/dist/runtime/cpython-resident.d.ts.map +1 -0
  11. package/dist/runtime/cpython-resident.js +133 -0
  12. package/dist/runtime/facet-loader-host.d.ts +15 -1
  13. package/dist/runtime/facet-loader-host.d.ts.map +1 -1
  14. package/dist/runtime/facet-loader-host.js +41 -0
  15. package/dist/runtime/package-manager.d.ts +3 -58
  16. package/dist/runtime/package-manager.d.ts.map +1 -1
  17. package/dist/runtime/package-manager.js +6 -143
  18. package/dist/runtime/python-repl.d.ts +1 -1
  19. package/dist/runtime/python-repl.d.ts.map +1 -1
  20. package/dist/runtime/python-repl.js +2 -2
  21. package/dist/runtime/ruby-runner.d.ts +1 -1
  22. package/dist/runtime/ruby-runner.d.ts.map +1 -1
  23. package/dist/runtime/ruby-runner.js +1 -1
  24. package/dist/runtime/runtime-catalog.d.ts +4 -80
  25. package/dist/runtime/runtime-catalog.d.ts.map +1 -1
  26. package/dist/runtime/runtime-catalog.js +4 -67
  27. package/dist/session/init.d.ts.map +1 -1
  28. package/dist/session/init.js +29 -41
  29. package/dist/session/keys.d.ts +13 -0
  30. package/dist/session/keys.d.ts.map +1 -1
  31. package/dist/session/keys.js +13 -0
  32. package/dist/session/nimbus-session.d.ts +19 -1
  33. package/dist/session/nimbus-session.d.ts.map +1 -1
  34. package/dist/session/nimbus-session.js +36 -1
  35. package/dist/session/programmatic.d.ts +2 -2
  36. package/dist/session/programmatic.d.ts.map +1 -1
  37. package/dist/session/programmatic.js +2 -1
  38. package/dist/session/supervisor-rpc.d.ts +6 -13
  39. package/dist/session/supervisor-rpc.d.ts.map +1 -1
  40. package/dist/session/supervisor-rpc.js +7 -17
  41. package/package.json +2 -2
  42. package/scripts/bundle-facet-workers.mjs +5 -5
  43. package/dist/runtime/bash-runner.d.ts +0 -63
  44. package/dist/runtime/bash-runner.d.ts.map +0 -1
  45. package/dist/runtime/bash-runner.generated.d.ts +0 -14
  46. package/dist/runtime/bash-runner.generated.d.ts.map +0 -1
  47. package/dist/runtime/bash-runner.generated.js +0 -13
  48. package/dist/runtime/bash-runner.js +0 -291
  49. package/dist/runtime/cpython-runner.d.ts +0 -71
  50. package/dist/runtime/cpython-runner.d.ts.map +0 -1
  51. package/dist/runtime/cpython-runner.js +0 -535
  52. package/dist/runtime/python-pip.d.ts +0 -38
  53. package/dist/runtime/python-pip.d.ts.map +0 -1
  54. package/dist/runtime/python-pip.js +0 -1063
  55. package/dist/runtime/runtime-registry.d.ts +0 -162
  56. package/dist/runtime/runtime-registry.d.ts.map +0 -1
  57. package/dist/runtime/runtime-registry.js +0 -364
  58. package/dist/runtime/virtual-socket-kernel.generated.d.ts +0 -14
  59. package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +0 -1
  60. package/dist/runtime/virtual-socket-kernel.generated.js +0 -13
  61. package/dist/runtime/wasm-runner.d.ts +0 -70
  62. package/dist/runtime/wasm-runner.d.ts.map +0 -1
  63. package/dist/runtime/wasm-runner.js +0 -664
@@ -1,664 +0,0 @@
1
- /**
2
- * wasm-runner.ts — native-WASM runner via the LOADER-modules transport.
3
- *
4
- * Direct `WebAssembly.instantiate(bytes)` is blocked by workerd CSP at
5
- * request time in both the supervisor and facet isolates. This runner routes
6
- * through the LOADER modules map:
7
- * bytes ride INSIDE the worker code blob, workerd compiles them
8
- * during the inner worker's MODULE-LOAD phase (the one phase where
9
- * wasm code generation IS allowed), and the resulting
10
- * WebAssembly.Module is exposed to the user fn via the
11
- * NimbusLoaderPool's `globalThis.__NIMBUS_WASM[<name>]` table.
12
- *
13
- * Shell command shape
14
- * ───────────────────
15
- *
16
- * wasm-runner --version
17
- * wasm-runner <file.wasm> <exportName> [int args...]
18
- *
19
- * Each invocation:
20
- * 1. Reads bytes from VFS (or any caller-supplied source).
21
- * 2. Allocates a PID via the process supervisor (Process tab integration).
22
- * 3. NimbusLoaderPool.submit() with wasmModules: { 'user.wasm': bytes }
23
- * — pool merges per-call wasm with constructor-time entries,
24
- * generates a worker.js that imports './user.wasm', and ships
25
- * the modules map to env.LOADER.get(...).
26
- * 4. The submitted fn runs inside the inner facet:
27
- * - reads globalThis.__NIMBUS_WASM['user.wasm'] (the precompiled
28
- * Module the pool registered)
29
- * - WebAssembly.instantiate(module, {}) — allowed because the
30
- * Module is precompiled
31
- * - looks up the export, calls with parsed integer args, returns
32
- * the result + the export list
33
- * 5. Supervisor formats and writes stdout/stderr; exit code 0/1.
34
- *
35
- * Limitations (documented in --help):
36
- * - Function args are integers only (parseInt). Float / string /
37
- * multi-arg-shapes need a wrapper module.
38
- * - Only WebAssembly.Memory and integer return values are surfaced.
39
- * - WASI imports are NOT provided. Modules expecting wasi_snapshot
40
- * won't instantiate (fail at the in-facet instantiate step).
41
- *
42
- * Dispatch constraints
43
- * ────────────────────
44
- * - No sleeps, caller-side retries, or catch-and-continue around loader
45
- * failures. The pool's resilience options own retry behavior.
46
- * - The try/catch around vfs.readFile is a legitimate I/O boundary;
47
- * the diagnostic propagates as exitCode 1 + stderr line.
48
- * - NO direct WebAssembly.instantiate(bytes) at request time —
49
- * workerd CSP rejects that path.
50
- */
51
- import { requireVfsCred, WASM32_WASI_NIMBUS_ABI } from '@nimbus-sh/core/runtime/os-contracts.js';
52
- import { WASI_INSTANCE_PREAMBLE_SRC, WASI_IMPLEMENTED_FNS, WASI_ABI_NAMESPACE } from '@nimbus-sh/core/runtime/wasi-instance.js';
53
- import { inspectWasmThreads, wasiThreadsLoadError } from '@nimbus-sh/core/runtime/wasi-threads.js';
54
- import { manifestVfs } from '@nimbus-sh/core/runtime/vfs-manifest.js';
55
- import { withMemoryLimit, DEFAULT_WASM_PROCESS_LIMIT_BYTES } from '@nimbus-sh/core/runtime/wasm-memory.js';
56
- export const WASM_RUNNER_VERSION = '0.3.0';
57
- export const WASM_RUNNER_HELP = 'Usage: wasm-runner [options] <file.wasm> [exportName] [int args...]\n' +
58
- ' wasm-runner --version\n' +
59
- ' wasm-runner --wasi-info\n' +
60
- '\n' +
61
- 'Loads a .wasm module and runs it. Two modes auto-detected from the\n' +
62
- 'module\'s imports:\n' +
63
- '\n' +
64
- ' WASI mode (imports wasi_snapshot_preview1): invokes _start with a\n' +
65
- ' core WASI WASI shim. stdout/stderr stream to the Process tab.\n' +
66
- ' exportName argument is optional; defaults to _start.\n' +
67
- ' Direct mode (no WASI imports): calls the named export with integer\n' +
68
- ' args and prints the return value.\n' +
69
- '\n' +
70
- 'Examples:\n' +
71
- ' wasm-runner ./hello.wasm # WASI, runs _start\n' +
72
- ' wasm-runner ./hello.wasm a b c # WASI, args [a,b,c]\n' +
73
- ' wasm-runner ./add.wasm add 3 4 # direct, → 7\n' +
74
- ' wasm-runner ./fib.wasm fib 10 # direct, → 55\n' +
75
- '\n' +
76
- 'Limitations (direct mode):\n' +
77
- ' - Function args are integers only (parseInt). Float / string /\n' +
78
- ' multi-arg-shapes need a wrapper module.\n' +
79
- ' - Only integer return values are surfaced.\n' +
80
- '\n' +
81
- 'Limitations (WASI mode, core WASI):\n' +
82
- ` - target ABI: ${WASM32_WASI_NIMBUS_ABI.id}.\n` +
83
- ' - implemented imports: ' + WASI_IMPLEMENTED_FNS.join(', ') + '.\n' +
84
- ' - filesystem access is rooted at the current Nimbus VFS subtree and\n' +
85
- ' flushed back after process exit.\n' +
86
- ' - fd 0 (stdin) returns EOF immediately.\n' +
87
- ' - pthreads / wasi-threads run CORRECTLY but never in parallel: one core,\n' +
88
- ' one thread at a time. Build with --target=wasm32-wasip1-threads -pthread\n' +
89
- ' -Wl,--import-memory,--shared-memory,--max-memory=<bytes> and link\n' +
90
- ' runtime-contracts/nimbus-threads.c; other threads builds are rejected.\n' +
91
- ' - Transport: bytes ship via the LOADER modules map, NOT\n' +
92
- ' WebAssembly.instantiate(bytes) at request time (CSP-blocked).';
93
- export function formatWasmRunnerWasiInfo() {
94
- return JSON.stringify({
95
- abi: WASM32_WASI_NIMBUS_ABI.id,
96
- os: WASM32_WASI_NIMBUS_ABI.os,
97
- target: WASM32_WASI_NIMBUS_ABI.target,
98
- env: WASM32_WASI_NIMBUS_ABI.env,
99
- capabilities: WASM32_WASI_NIMBUS_ABI.capabilities,
100
- imports: WASI_IMPLEMENTED_FNS,
101
- }, null, 2) + '\n';
102
- }
103
- /**
104
- * Cheap supervisor-side WASI-detect: scan the wasm import section
105
- * header bytes for the literal `wasi_snapshot_preview1` module name.
106
- * No full parser — we just walk the import section and check the
107
- * module-name string of each entry. False positives are not possible
108
- * because import-section module names are length-prefixed UTF-8
109
- * blocks; a substring match against the raw bytes is sufficient
110
- * (the literal "wasi_snapshot_preview1" doesn't appear inside any
111
- * other section's well-formed payload at the import position).
112
- *
113
- * This avoids `WebAssembly.Module.imports(mod)` which can only run
114
- * inside a context that holds a precompiled Module — we don't yet
115
- * have one in the supervisor (CSP blocks request-time compile).
116
- */
117
- function detectWasiAbi(bytes) {
118
- // Recognise BOTH 'wasi_snapshot_preview1' (modern) AND 'wasi_unstable'
119
- // (preview0, what binji-linked binaries import). Which one matters: the two
120
- // share every function name and every signature but disagree on fd_seek's
121
- // whence constants and on the filestat layout, so binding the wrong one
122
- // never traps — it silently returns wrong offsets and wrong file sizes.
123
- // 'wasi_unstable' is not a substring of 'wasi_snapshot_preview1', so the
124
- // two needles cannot be confused; a module carrying both is preview1.
125
- const enc = new TextEncoder();
126
- const needles = [
127
- [enc.encode('wasi_snapshot_preview1'), 'preview1'],
128
- [enc.encode('wasi_unstable'), 'preview0'],
129
- ];
130
- for (const [needle, abi] of needles) {
131
- if (bytes.length < needle.length)
132
- continue;
133
- outer: for (let i = 0; i <= bytes.length - needle.length; i++) {
134
- for (let j = 0; j < needle.length; j++) {
135
- if (bytes[i + j] !== needle[j])
136
- continue outer;
137
- }
138
- return abi;
139
- }
140
- }
141
- return null;
142
- }
143
- /**
144
- * Build a `run` function suitable for RuntimeSpec.run(). Parameterised
145
- * over the VFS, env (for env.LOADER), ctx (for the pool's doId-scoped
146
- * cache key), and the session process supervisor (for `ps` /
147
- * `logs <pid>` / Process tab integration). Returns a fn that matches
148
- * the runtime-registry's contract.
149
- */
150
- export function makeWasmRunner(deps) {
151
- return async function runWasm(_facetMgr, _code, opts) {
152
- const vfs = deps.vfs.as(requireVfsCred(opts.cred, 'wasm-runner'));
153
- // opts.filename is the resolved .wasm path (absolute, /-prefixed
154
- // by the registry's bypassesScriptRead path).
155
- // opts.argv is:
156
- // WASI mode: [<extra-args-to-program>...] (or empty)
157
- // direct mode: [exportName, intArg1, intArg2, ...]
158
- const wasmPath = (opts.filename || '').replace(/^\/+/, '');
159
- const argv = opts.argv || [];
160
- let bytes;
161
- try {
162
- if (!vfs.exists(wasmPath)) {
163
- return {
164
- exitCode: 1,
165
- stdout: '',
166
- stderr: `wasm-runner: cannot find module '${opts.filename}'\n`,
167
- };
168
- }
169
- bytes = vfs.readFile(wasmPath);
170
- }
171
- catch (e) {
172
- return {
173
- exitCode: 1,
174
- stdout: '',
175
- stderr: `wasm-runner: cannot read '${opts.filename}': ${e instanceof Error ? e.message : String(e)}\n`,
176
- };
177
- }
178
- // Detect WASI imports BEFORE parsing argv as direct-mode integers.
179
- // WASI mode treats every argv token as a string passed to the
180
- // program; direct mode treats argv[0] as export name and the rest
181
- // as integers.
182
- const wasiAbi = detectWasiAbi(bytes);
183
- const isWasi = wasiAbi !== null;
184
- // Threads are decided here, from the binary, so an unsupported build is
185
- // rejected before a facet is ever spawned and the diagnosis names the
186
- // build line rather than a trap deep inside libc.
187
- const threadsInfo = inspectWasmThreads(bytes);
188
- const threadsError = wasiThreadsLoadError(threadsInfo);
189
- if (threadsError) {
190
- return { exitCode: 1, stdout: '', stderr: `wasm-runner: ${threadsError}\n` };
191
- }
192
- const threads = threadsInfo.spawns && threadsInfo.memory
193
- ? {
194
- memory: {
195
- module: threadsInfo.memory.module,
196
- name: threadsInfo.memory.name,
197
- initial: threadsInfo.memory.initial,
198
- maximum: threadsInfo.memory.maximum,
199
- },
200
- }
201
- : undefined;
202
- let exportName;
203
- let parsedArgs = [];
204
- let wasiArgv = [];
205
- if (isWasi) {
206
- // WASI argv convention: argv[0] is the program name. Use the
207
- // module's filename (without leading slashes) so getopt-style
208
- // libraries see something sensible.
209
- const progName = (opts.filename || 'wasm').replace(/^\/+/, '').split('/').pop() || 'wasm';
210
- wasiArgv = [progName, ...argv];
211
- // Allow the user to pass `wasm-runner file.wasm _start` as a
212
- // hint that they really want the _start entry (matches the
213
- // existing direct-mode invocation shape so probes can be the
214
- // same). _start is the default for WASI anyway.
215
- if (argv.length > 0 && argv[0] === '_start') {
216
- wasiArgv = [progName, ...argv.slice(1)];
217
- }
218
- }
219
- else {
220
- exportName = argv[0];
221
- const intArgs = argv.slice(1);
222
- if (!exportName) {
223
- return {
224
- exitCode: 1,
225
- stdout: '',
226
- stderr: 'wasm-runner: missing export name\n' +
227
- `Usage: wasm-runner ${opts.filename} <exportName> [int args...]\n`,
228
- };
229
- }
230
- // Parse integer args. Non-integer values are reported as a clear
231
- // diagnostic rather than silently coerced (Number() would map
232
- // 'foo' → NaN which the wasm fn would treat as 0 — confusing).
233
- for (let i = 0; i < intArgs.length; i++) {
234
- const n = parseInt(intArgs[i], 10);
235
- if (!Number.isFinite(n)) {
236
- return {
237
- exitCode: 1,
238
- stdout: '',
239
- stderr: `wasm-runner: argument ${i + 1} ('${intArgs[i]}') is not an integer\n`,
240
- };
241
- }
242
- parsedArgs.push(n);
243
- }
244
- }
245
- // Install a declared memory maximum before the bytes leave for the
246
- // loader. Modules built by wasi-sdk declare a minimum and no maximum, so
247
- // an unbounded `memory.grow` runs until the facet isolate is killed and
248
- // the guest never learns it ran out of memory. With a maximum in place
249
- // the grow instruction returns -1 instead, malloc gets NULL, and the
250
- // program fails through its own error path with the isolate intact.
251
- //
252
- // A module that declares a tighter maximum keeps it, and one whose
253
- // minimum exceeds the cap is left alone: refusing to run a program we
254
- // could have run is a worse outcome than the OOM this prevents, and the
255
- // supervisor cannot report a compile failure as usefully as the guest can
256
- // report its own allocation failure.
257
- //
258
- // A wasi-threads build is untouched: it imports its shared memory and
259
- // names the ceiling on its own build line (`--max-memory`), so there is no
260
- // memory section to rewrite and no unbounded growth to prevent.
261
- let limited = bytes;
262
- try {
263
- limited = withMemoryLimit(bytes, DEFAULT_WASM_PROCESS_LIMIT_BYTES);
264
- }
265
- catch (e) {
266
- console.warn(`wasm-runner: leaving '${opts.filename}' uncapped: ` +
267
- (e instanceof Error ? e.message : String(e)));
268
- }
269
- // Convert Uint8Array (SqliteVFS native) into ArrayBuffer.
270
- // structuredClone-safe ArrayBuffer is required by the pool's
271
- // wasmModules contract; sub-views aren't accepted by workerd's
272
- // modules map either. The slice() call always returns a fresh
273
- // ArrayBuffer regardless of whether bytes.buffer was originally
274
- // a Shared variant — TS's overload-resolution narrowing here is
275
- // overly conservative; cast to ArrayBuffer is correct.
276
- const buf = limited.buffer.slice(limited.byteOffset, limited.byteOffset + limited.byteLength);
277
- const facetFn = async function wasmFacetCall(args, facetEnv) {
278
- const wasmTable = globalThis.__NIMBUS_WASM || {};
279
- const mod = wasmTable['user.wasm'];
280
- if (!mod) {
281
- return {
282
- ok: false,
283
- mode: args.mode,
284
- error: 'globalThis.__NIMBUS_WASM[\'user.wasm\'] not found — the pool ' +
285
- 'did not register the module. Internal error.',
286
- };
287
- }
288
- // ── WASI mode ──
289
- if (args.mode === 'wasi') {
290
- const mk = __wasiMakeImports;
291
- const runStart = __wasiRunStart;
292
- // WASI socket and polling support P3 / production compatibility fix: bare lexical reference, matching
293
- // the runStart pattern above. The earlier `(globalThis as any)
294
- // .__wasiRunStartAsync` lookup returned undefined at runtime
295
- // because top-level `function` declarations in the preamble's
296
- // ES-module scope do NOT auto-attach to globalThis. The result
297
- // was that sock_*/poll_oneoff (wrapped in WebAssembly.Suspending)
298
- // were invoked from a sync `_start` call stack → V8 trapped with
299
- // "trying to suspend without WebAssembly.promising". The 11
300
- // sync-only WASI socket and polling support probes worked because they never hit a
301
- // Suspending import; the 7 async probes failed because they did.
302
- // The preamble is statically prepended to this same module body
303
- // (loader-pool.ts:523-530), so the symbol is guaranteed in
304
- // scope. typeof guard handles the impossible case of a preamble
305
- // pre-dating WASI socket and polling support (defensive only).
306
- const runStartAsync = typeof __wasiRunStartAsync === 'function'
307
- ? __wasiRunStartAsync
308
- : null;
309
- const initFS = __wasiInitFS;
310
- if (!mk || !runStart || !initFS) {
311
- return {
312
- ok: false,
313
- mode: 'wasi',
314
- error: 'WASI preamble missing: __wasi* helpers not defined. ' +
315
- 'Pool preamble may have failed to load.',
316
- };
317
- }
318
- // Install the seed manifest. fd 3 = the user's session root preopen.
319
- // The shim's fd table is reset by initFS each call.
320
- if (args.wasiFs) {
321
- initFS({
322
- root: args.wasiFs.root,
323
- preopens: args.wasiFs.preopens,
324
- files: args.wasiFs.files,
325
- dirs: args.wasiFs.dirs,
326
- modes: args.wasiFs.modes,
327
- sizes: args.wasiFs.sizes,
328
- enumeratedRoots: args.wasiFs.enumeratedRoots,
329
- revision: args.wasiFs.revision,
330
- });
331
- // initFS resets the live state, so adoption has to follow it. From
332
- // here the seed is a cache: content it did not carry is fetched on
333
- // demand and writes go back as they happen.
334
- __wasiAdoptSupervisor(facetEnv && facetEnv.SUPERVISOR);
335
- }
336
- else {
337
- // Not for null-safety any more — the shim starts with an empty
338
- // filesystem. This stays because initFS is also what RESETS per-call
339
- // state: the fd table, the preopen list, the persist queue and the
340
- // negative-lookup cache. A pooled isolate that skipped it would hand
341
- // this program the previous one's descriptors.
342
- initFS({ root: '', preopens: [], files: {}, dirs: [], modes: {} });
343
- }
344
- const memRef = { mem: null };
345
- const abi = args.wasiAbi || 'preview1';
346
- const wasi = mk({
347
- argv: args.wasiArgv || [],
348
- env: args.wasiEnv || {},
349
- abi,
350
- threads: !!args.threads,
351
- // Non-null by ordering, not by check. The import table is only ever
352
- // CALLED from inside the guest, and the guest cannot run before
353
- // `_start` below, by which point memRef.mem is assigned or the call
354
- // has already returned an error. The shim dereferences the result
355
- // unguarded, so if the ordering ever stops holding, the failure is a
356
- // TypeError raised inside a suspended syscall.
357
- getMemory: () => memRef.mem,
358
- });
359
- // Bind ONLY the namespace this module actually imports, with the
360
- // import table built for that ABI. Aliasing one preview1 table onto
361
- // both names — which this did until the encodings were checked
362
- // against the binaries — gives a preview0 guest inverted fd_seek
363
- // whence and a 64-byte filestat it decodes as 56, so every lseek
364
- // lands wrong and every st_size reads back as the nlink field. The
365
- // signatures are identical, so nothing traps and nothing is logged.
366
- // The one place the precise table meets WebAssembly's own types, which
367
- // describe an import object as an untyped index signature. Widening
368
- // here keeps the precision on the shim's side of the boundary.
369
- const importObject = {
370
- [args.wasiNamespace || 'wasi_snapshot_preview1']: wasi.wasiImport,
371
- };
372
- // A threads build imports its memory instead of defining one, because
373
- // every thread is another instance and they must all address the same
374
- // bytes. The host creates it — shared, at the module's declared limits
375
- // — and the scheduler, the syscall layer and each thread instance all
376
- // read through this one object.
377
- let sched = null;
378
- if (args.threads) {
379
- let shared;
380
- try {
381
- shared = new WebAssembly.Memory({
382
- initial: args.threads.memory.initial,
383
- maximum: args.threads.memory.maximum,
384
- shared: true,
385
- });
386
- }
387
- catch (e) {
388
- // A shared memory reserves its MAXIMUM up front, so an over-large
389
- // --max-memory fails here rather than when the program grows into
390
- // it. Say which number did it; the alternative message is a bare
391
- // RangeError with no link to the build line that chose it.
392
- return {
393
- ok: false,
394
- mode: 'wasi',
395
- error: `wasi-threads: could not reserve the shared memory the module declares `
396
- + `(${args.threads.memory.initial}–${args.threads.memory.maximum} pages, `
397
- + `${(args.threads.memory.maximum * 64) / 1024} MiB): ${e?.message || e}. `
398
- + 'A shared memory reserves its maximum immediately — lower --max-memory.',
399
- };
400
- }
401
- memRef.mem = shared;
402
- importObject[args.threads.memory.module] = {
403
- ...(importObject[args.threads.memory.module] || {}),
404
- [args.threads.memory.name]: shared,
405
- };
406
- sched = __wasiThreadsCreate({
407
- memory: shared,
408
- startThread: __wasiThreadsStarter(mod, importObject),
409
- });
410
- Object.assign(importObject, sched.hostImports());
411
- }
412
- let inst;
413
- try {
414
- const result = await WebAssembly.instantiate(mod, importObject);
415
- inst = (result instanceof WebAssembly.Instance ? result : result.instance);
416
- }
417
- catch (e) {
418
- return {
419
- ok: false,
420
- mode: 'wasi',
421
- error: `instantiate failed: ${e?.message || e}`,
422
- };
423
- }
424
- if (!memRef.mem)
425
- memRef.mem = inst.exports.memory;
426
- if (!memRef.mem) {
427
- return {
428
- ok: false,
429
- mode: 'wasi',
430
- error: 'wasm module did not export a `memory` — WASI requires one.',
431
- };
432
- }
433
- // WASI socket and polling support P3: use async runStart when available so any
434
- // suspending socket imports can complete via JSPI. The async
435
- // wrapper falls back to sync invocation internally when
436
- // WebAssembly.promising isn't available, so this is safe for
437
- // non-suspending programs too. Legacy preambles (pre-WASI socket and polling support)
438
- // that ship without __wasiRunStartAsync still work via the
439
- // sync runStart path.
440
- const r = sched
441
- ? await __wasiRunStartThreads(inst, sched)
442
- : runStartAsync
443
- ? await runStartAsync(inst, { memory: memRef.mem })
444
- : runStart(inst, { memory: memRef.mem });
445
- // Writes reached the session VFS as they happened; this waits for the
446
- // queue so the caller cannot observe a result before the data lands.
447
- await __wasiDrainPersist();
448
- return {
449
- ok: r.exitCode === 0 && !r.error,
450
- mode: 'wasi',
451
- stdout: wasi.getStdout(),
452
- stderr: wasi.getStderr(),
453
- exitCode: r.exitCode,
454
- exports: Object.keys(inst.exports),
455
- error: r.error,
456
- };
457
- }
458
- // ── Direct mode ──
459
- let inst;
460
- try {
461
- // Single-arg instantiate against a precompiled Module — this
462
- // is the form workerd's CSP DOES allow. The dynamic-bytes
463
- // form (instantiate(ArrayBuffer)) is what's blocked.
464
- const result = await WebAssembly.instantiate(mod, {});
465
- inst = (result instanceof WebAssembly.Instance ? result : result.instance);
466
- }
467
- catch (e) {
468
- return {
469
- ok: false,
470
- mode: 'direct',
471
- error: `instantiate failed: ${e?.message || e}`,
472
- };
473
- }
474
- const exportNames = Object.keys(inst.exports);
475
- const fn = inst.exports[args.exportName];
476
- if (typeof fn !== 'function') {
477
- return {
478
- ok: false,
479
- mode: 'direct',
480
- exports: exportNames,
481
- error: `export '${args.exportName}' is not a function (or not exported). ` +
482
- `Available exports: ${exportNames.join(', ')}`,
483
- };
484
- }
485
- let out;
486
- try {
487
- out = fn(...(args.intArgs || []));
488
- }
489
- catch (e) {
490
- return {
491
- ok: false,
492
- mode: 'direct',
493
- exports: exportNames,
494
- error: `${args.exportName}(${(args.intArgs || []).join(', ')}) threw: ${e?.message || e}`,
495
- };
496
- }
497
- // BigInt (i64) → string; everything else → as-is.
498
- if (typeof out === 'bigint')
499
- return { ok: true, mode: 'direct', result: out.toString(), exports: exportNames };
500
- return { ok: true, mode: 'direct', result: out, exports: exportNames };
501
- };
502
- // PID + log integration. The runtime-registry's contract is
503
- // runtime-agnostic at the PID layer; node + bun get this for
504
- // free via runFresh → facetMgr.exec which spawns through the
505
- // process supervisor. wasm-runner uses NimbusLoaderPool directly
506
- // (compute-only, no SUPERVISOR binding needed) so we have to
507
- // allocate the PID + log entries by hand.
508
- const cmdLabel = 'wasm-runner ' +
509
- (opts.filename || '').replace(/^\/+/, '/') +
510
- ' ' +
511
- argv.join(' ');
512
- const procEntry = deps.processes.spawn(cmdLabel.trim(), ['wasm-runner', ...argv], opts.cwd || '/home/user');
513
- const pid = procEntry.pid;
514
- // Pass-through env vars (Nimbus shell sets HOME/USER/PATH/etc.). The
515
- // runtime-registry's RuntimeRunOpts carries env on the way in; we
516
- // forward to the WASI shim. Direct mode doesn't use env.
517
- const wasiEnv = isWasi
518
- ? { ...(opts.env || {}), ...WASM32_WASI_NIMBUS_ABI.env }
519
- : {};
520
- // ── filesystem WASI: seed a manifest of the user's session VFS ──
521
- //
522
- // The user's cwd at invocation time is the session-root preopen anchor.
523
- // WASI programs see it as fd 3 mapped to '/'. The seed describes the
524
- // subtree rather than copying it: content is demand-loaded through the
525
- // supervisor on first read and writes go back as they happen, so a
526
- // program that never exits still persists.
527
- //
528
- // For direct mode there's no FS exposure — wasm runs in pure
529
- // compute-only mode, no preopens.
530
- let wasiFs;
531
- let wasiFsBytes = 0;
532
- let wasiFsFiles = 0;
533
- if (isWasi) {
534
- // Session root = cwd of the shell invocation. Falls back to /home/user.
535
- const cwd = (opts.cwd || '/home/user').replace(/^\/+/, '');
536
- const seed = manifestVfs(vfs, cwd, { revision: vfs.revision(cwd) });
537
- if ('error' in seed) {
538
- return {
539
- exitCode: 1,
540
- stdout: '',
541
- stderr: `wasm-runner: ${seed.error}\n`,
542
- };
543
- }
544
- wasiFs = {
545
- ...seed.snapshot,
546
- // fd 3 → '/' mapping (covers the user's session subtree).
547
- preopens: [{ wasiPath: '/', vfsPath: seed.snapshot.root }],
548
- };
549
- wasiFsBytes = seed.bytes;
550
- wasiFsFiles = seed.files;
551
- }
552
- let outcome;
553
- try {
554
- // Built here, not earlier: the pool bakes the invoking process's pid
555
- // into the SUPERVISOR binding's props, and the pid does not exist until
556
- // the process is spawned above. The supervisor derives the write
557
- // credential from it, so a pool that binds SUPERVISOR without one has a
558
- // filesystem that can read but never write.
559
- const { NimbusLoaderPool } = await import('../loaders/loader-pool.js');
560
- const pool = new NimbusLoaderPool(deps.env, deps.ctx, {
561
- tag: isWasi ? 'wasm-runner-wasi' : 'wasm-runner',
562
- concurrency: 1,
563
- // WASI mode needs the SUPERVISOR binding: it is what backs the
564
- // filesystem with the live session VFS instead of a spawn-time copy.
565
- // Direct (compute-only) mode has no filesystem at all, so it keeps the
566
- // bindings table empty and the facet isolate boots fast.
567
- omitSupervisor: !isWasi,
568
- supervisorPid: pid,
569
- // WASI mode: ship the WASI shim source as a module-init preamble
570
- // so `__wasiMakeImports` is in scope when the facet fn runs.
571
- // Direct mode: no preamble (saves a few KB per submit).
572
- preamble: isWasi ? WASI_INSTANCE_PREAMBLE_SRC : undefined,
573
- });
574
- const submitArgs = isWasi
575
- ? {
576
- mode: 'wasi',
577
- wasiArgv,
578
- wasiEnv,
579
- wasiAbi: wasiAbi ?? undefined,
580
- wasiNamespace: WASI_ABI_NAMESPACE[wasiAbi ?? 'preview1'],
581
- threads,
582
- wasiFs,
583
- }
584
- : { mode: 'direct', exportName: exportName, intArgs: parsedArgs };
585
- outcome = (await pool.submit(facetFn, submitArgs, {
586
- wasmModules: { 'user.wasm': buf },
587
- // 30s ceiling for compute. Most wasm calls return in
588
- // microseconds; runaway loops hit this and the pool returns
589
- // a TimeoutError that surfaces as exitCode 1 + stderr.
590
- timeoutMs: 30_000,
591
- }));
592
- }
593
- catch (e) {
594
- outcome = { ok: false, error: `dispatch failed: ${e?.message || e}` };
595
- }
596
- let exitCode;
597
- let stdout;
598
- let stderr;
599
- // The facet's `ok` field encodes "clean exit (code 0, no trap)" — but
600
- // for WASI mode, a non-zero proc_exit IS legitimate program output,
601
- // not a wasm-runner error. Branch on `mode` first so we surface the
602
- // program's exit code unchanged.
603
- if (outcome.mode === 'wasi') {
604
- // WASI mode: pass through stdout/stderr the wasm wrote via
605
- // fd_write. Exit code from proc_exit (or 0 on natural fall-through).
606
- // If runStart reported an `error` (wasm trapped, _start missing,
607
- // …), append it to stderr but still surface its exitCode (default
608
- // 1 from runStart on trap) so callers can distinguish.
609
- const wasiOut = outcome;
610
- // Either branch carries optional stdout/stderr/exitCode/error.
611
- stdout = wasiOut.stdout || '';
612
- stderr = wasiOut.stderr || '';
613
- if (wasiOut.error) {
614
- stderr = (stderr ? stderr : '') +
615
- `wasm-runner: wasi trap: ${wasiOut.error}\n`;
616
- }
617
- exitCode = wasiOut.exitCode ?? (wasiOut.ok ? 0 : 1);
618
- }
619
- else if (!outcome.ok) {
620
- // Direct-mode failure or pre-instantiate dispatch failure — shell
621
- // sees rc=1 + stderr.
622
- exitCode = 1;
623
- stdout = '';
624
- stderr = `wasm-runner: ${outcome.error}\n`;
625
- }
626
- else {
627
- // Direct mode success: surface the result on stdout. void-return
628
- // is success with no output; callers chain `&& echo OK` to detect.
629
- stdout =
630
- outcome.result === undefined || outcome.result === null
631
- ? ''
632
- : String(outcome.result) + '\n';
633
- stderr = '';
634
- exitCode = 0;
635
- }
636
- // Mirror stdout/stderr into the per-PID ring so `logs <pid>`
637
- // and the Process tab WS log stream see the output. The
638
- // append-then-markExit ordering matches what shellExecuteTracked
639
- // does in init.ts:1559+ (Fix 5 contract).
640
- if (stdout) {
641
- try {
642
- deps.processes.appendOutput(pid, 'stdout', stdout);
643
- }
644
- catch { }
645
- }
646
- if (stderr) {
647
- try {
648
- deps.processes.appendOutput(pid, 'stderr', stderr);
649
- }
650
- catch { }
651
- }
652
- try {
653
- deps.processes.exit(pid, exitCode);
654
- }
655
- catch { }
656
- try {
657
- if (!deps.processes.getExit(pid)) {
658
- deps.processes.markExit(pid, exitCode);
659
- }
660
- }
661
- catch { }
662
- return { exitCode, stdout, stderr };
663
- };
664
- }