@nimbus-sh/core 0.1.0 → 0.3.0

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