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