@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.
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/runtime/bash-runner.d.ts +63 -0
- package/dist/runtime/bash-runner.d.ts.map +1 -0
- package/dist/runtime/bash-runner.generated.d.ts +14 -0
- package/dist/runtime/bash-runner.generated.d.ts.map +1 -0
- package/dist/runtime/bash-runner.generated.js +13 -0
- package/dist/runtime/bash-runner.js +290 -0
- package/dist/runtime/cpython-runner.d.ts +86 -0
- package/dist/runtime/cpython-runner.d.ts.map +1 -0
- package/dist/runtime/cpython-runner.js +425 -0
- package/dist/runtime/facet-host.d.ts +159 -0
- package/dist/runtime/facet-host.d.ts.map +1 -0
- package/dist/runtime/facet-host.js +22 -0
- package/dist/runtime/installed-runtimes.d.ts +99 -0
- package/dist/runtime/installed-runtimes.d.ts.map +1 -0
- package/dist/runtime/installed-runtimes.js +162 -0
- package/dist/runtime/local-facet-host.d.ts +38 -0
- package/dist/runtime/local-facet-host.d.ts.map +1 -0
- package/dist/runtime/local-facet-host.js +171 -0
- package/dist/runtime/python-pip.d.ts +38 -0
- package/dist/runtime/python-pip.d.ts.map +1 -0
- package/dist/runtime/python-pip.js +1063 -0
- package/dist/runtime/runtime-manifest.d.ts +86 -0
- package/dist/runtime/runtime-manifest.d.ts.map +1 -0
- package/dist/runtime/runtime-manifest.js +72 -0
- package/dist/runtime/runtime-package.d.ts +63 -0
- package/dist/runtime/runtime-package.d.ts.map +1 -0
- package/dist/runtime/runtime-package.js +66 -0
- package/dist/runtime/runtime-registry.d.ts +162 -0
- package/dist/runtime/runtime-registry.d.ts.map +1 -0
- package/dist/runtime/runtime-registry.js +363 -0
- package/dist/runtime/vfs-snapshot.d.ts.map +1 -1
- package/dist/runtime/vfs-snapshot.js +15 -1
- package/dist/runtime/vfs-supervisor.d.ts +22 -0
- package/dist/runtime/vfs-supervisor.d.ts.map +1 -0
- package/dist/runtime/vfs-supervisor.js +65 -0
- package/dist/runtime/virtual-socket-kernel.generated.d.ts +14 -0
- package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -0
- package/dist/runtime/virtual-socket-kernel.generated.js +13 -0
- package/dist/runtime/wasm-runner.d.ts +80 -0
- package/dist/runtime/wasm-runner.d.ts.map +1 -0
- package/dist/runtime/wasm-runner.js +686 -0
- package/dist/workspace/nimbus-workspace.d.ts +116 -20
- package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
- package/dist/workspace/nimbus-workspace.js +238 -45
- package/package.json +4 -2
- package/src/index.ts +16 -0
- package/src/runtime/bash-runner.generated.ts +14 -0
- package/src/runtime/bash-runner.ts +347 -0
- package/src/runtime/cpython-runner.ts +504 -0
- package/src/runtime/facet-host.ts +170 -0
- package/src/runtime/installed-runtimes.ts +235 -0
- package/src/runtime/local-facet-host.ts +205 -0
- package/src/runtime/python-pip.ts +1211 -0
- package/src/runtime/runtime-manifest.ts +155 -0
- package/src/runtime/runtime-package.ts +114 -0
- package/src/runtime/runtime-registry.ts +511 -0
- package/src/runtime/vfs-snapshot.ts +15 -1
- package/src/runtime/vfs-supervisor.ts +67 -0
- package/src/runtime/virtual-socket-kernel.generated.ts +14 -0
- package/src/runtime/wasm-runner.ts +835 -0
- 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
|
+
}
|