@earendil-works/djs 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1958 @@
1
+ // The Sandbox API: loads djs-wasm (docs/host.md) and runs code in a djs VM. Values go in
2
+ // through the wire format (wire.ts) and come out through direct heap reads (reader.ts).
3
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
4
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
5
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
6
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
7
+ });
8
+ }
9
+ return path;
10
+ };
11
+ import { IN_PLACE_MIN, encode, stringInPlace } from "./wire.js";
12
+ import { trapText } from "./trap.js";
13
+ import { Opaque, readValue } from "./reader.js";
14
+ import { provideBase, rememberBase } from "./base.js";
15
+ import { scriptFor as dumpRuntimeFor } from "./dump-runtime.js";
16
+ import { envVar } from "./env.js";
17
+ /** Why a sandbox operation failed. `value` is the thrown VM value (kind "threw"). */
18
+ export class SandboxError extends Error {
19
+ kind;
20
+ value;
21
+ constructor(kind, message, value) {
22
+ super(message);
23
+ this.name = "SandboxError";
24
+ this.kind = kind;
25
+ this.value = value;
26
+ }
27
+ }
28
+ const STATUS_DONE = 0;
29
+ const STATUS_THREW = 1;
30
+ const STATUS_OUT_OF_FUEL = 2;
31
+ const STATUS_HOST_CALL = 3;
32
+ const STATUS_IDLE = 4;
33
+ const STATUS_HOST_OUT_OF_MEMORY = 5;
34
+ const ABI_VERSION = 5;
35
+ /** `vm_new_with`'s flag: frozen builtins (D2304). */
36
+ const VM_FROZEN_BUILTINS = 1;
37
+ const CONSOLE_METHODS = ["log", "info", "warn", "error"];
38
+ /**
39
+ * A plain dump's host hooks (D2832, D2890): the global object `$djs` with `resume(id,
40
+ * name, args, state)` and `seed(lo, hi)`, defined while the dump's Script runs. Their
41
+ * function ids are outside the host functions' (ids are places in `fnNames`).
42
+ */
43
+ const PLAIN_HOOKS = "$djs";
44
+ const HOOK_RESUME = 0x7fff_fff0;
45
+ const HOOK_SEED = 0x7fff_fff1;
46
+ const HOOK_SOURCE = 0x7fff_fff2;
47
+ const HOOK_HOST = 0x7fff_fff3;
48
+ /** An identity `ns.member` of two identifiers: member `member` of the namespace `ns` (D2632). */
49
+ const NAMESPACE_MEMBER = /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/;
50
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
51
+ /**
52
+ * The codemode bodies the sandboxes of this process ran, by name, newest first: what a
53
+ * dump's `$djs.source(name, hash)` refers to (D2882). A sandbox in another process gets
54
+ * them from the `sources` option. A few per name are kept.
55
+ */
56
+ const ranScripts = new Map();
57
+ const SCRIPTS_KEPT = 16;
58
+ /**
59
+ * The hash of `$djs.source(name, hash, params)` (D2882, `vm::script_hash`): FNV-1a 64 of
60
+ * the UTF-8 of the parameter names (`,`-joined), the compile option bits in decimal and
61
+ * the source, `\0`-separated, folded to 32 bits (D2839), as 8 hex digits.
62
+ */
63
+ export function scriptHash(source, params, bits) {
64
+ let h = 0xcbf29ce484222325n;
65
+ const step = (bytes) => {
66
+ for (const b of bytes)
67
+ h = BigInt.asUintN(64, (h ^ BigInt(b)) * 0x100000001b3n);
68
+ };
69
+ step(textEncoder.encode(params));
70
+ step(new Uint8Array([0]));
71
+ step(textEncoder.encode(String(bits)));
72
+ step(new Uint8Array([0]));
73
+ step(textEncoder.encode(source));
74
+ return BigInt.asUintN(32, h ^ (h >> 32n)).toString(16).padStart(8, "0");
75
+ }
76
+ function rememberScript(name, code) {
77
+ const list = (ranScripts.get(name) ?? []).filter((c) => c !== code);
78
+ list.unshift(code);
79
+ if (list.length > SCRIPTS_KEPT)
80
+ list.length = SCRIPTS_KEPT;
81
+ ranScripts.set(name, list);
82
+ }
83
+ /** A plain dump (D2830): `"djs-dump";`, and whether its text is TypeScript. */
84
+ export function plainDump(text) {
85
+ const m = /^\s*"djs-dump( typescript)?";/.exec(text);
86
+ return m ? { typescript: m[1] !== undefined } : undefined;
87
+ }
88
+ /** The modules being compiled or compiled, by location. */
89
+ const modules = new Map();
90
+ /** The compiled ones, for the synchronous constructor. */
91
+ const compiled = new Map();
92
+ /**
93
+ * The dump module's sandboxes, one per module and memory limit in the process (D2802):
94
+ * every sandbox with that `dumpModule` moves its image into the same instance and VM for
95
+ * a dump or a load, which runs no host function (the loader refuses host calls), so the
96
+ * VM is the only state they share, and it is replaced each time.
97
+ */
98
+ const dumpers = new WeakMap();
99
+ /**
100
+ * The default location of djs-wasm: `$DJS_WASM`, else, running from the repository's
101
+ * sources (js/src/*.ts), the `wasm` profile build. The package's entry points have their
102
+ * own defaults (`Sandbox.defaultModule`), the modules shipped in the package (D3106).
103
+ */
104
+ export function defaultWasmPath() {
105
+ const env = envVar("DJS_WASM");
106
+ if (env !== undefined)
107
+ return env;
108
+ // Not `new URL("…", import.meta.url)` literally: bundlers take that for an asset of the
109
+ // package, and the built package has no repository around it.
110
+ const here = import.meta.url;
111
+ if (here.endsWith(".ts"))
112
+ return new URL("../../target/wasm32-unknown-unknown/wasm/djs_wasm.wasm", here);
113
+ throw new SandboxError("misuse", "no default djs-wasm module: pass a location or set $DJS_WASM");
114
+ }
115
+ /**
116
+ * Compiles djs-wasm once per location and caches the `WebAssembly.Module` (ARCH: the
117
+ * module is compiled once; every sandbox gets its own instance). A `file:` URL, or a
118
+ * string that is no URL in node, Bun or Deno, is read from the file system; anything else
119
+ * is fetched (in a browser, a relative string is relative to the page).
120
+ */
121
+ export function loadModule(location = defaultWasmPath()) {
122
+ const key = String(location);
123
+ let m = modules.get(key);
124
+ if (m === undefined) {
125
+ m = (async () => {
126
+ let bytes;
127
+ if (isFile(location)) {
128
+ // Not a literal specifier, so the package type-checks without node's types, and
129
+ // bundlers for the browser leave it alone.
130
+ const fsModule = "node:fs/promises";
131
+ const fs = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ /* webpackIgnore: true */ fsModule)));
132
+ bytes = await fs.readFile(typeof location === "string" && /^file:/i.test(location) ? new URL(location) : location);
133
+ }
134
+ else {
135
+ const r = await fetch(location);
136
+ if (!r.ok)
137
+ throw new SandboxError("misuse", `cannot fetch djs-wasm from ${key}: HTTP ${r.status}`);
138
+ bytes = new Uint8Array(await r.arrayBuffer());
139
+ }
140
+ return WebAssembly.compile(bytes);
141
+ })();
142
+ modules.set(key, m);
143
+ m.then((mod) => compiled.set(key, mod), () => modules.delete(key));
144
+ }
145
+ return m;
146
+ }
147
+ /** Whether `loadModule` reads `location` from the file system (else it fetches it). */
148
+ function isFile(location) {
149
+ // A scheme of two letters or more: `C:\x` is a path.
150
+ const url = typeof location === "string" ? /^([a-z][a-z0-9+.-]+):/i.exec(location)?.[1] : location.protocol.slice(0, -1);
151
+ if (url !== undefined)
152
+ return url.toLowerCase() === "file";
153
+ const g = globalThis;
154
+ return g.process?.versions?.node !== undefined || g.Deno !== undefined || g.Bun !== undefined;
155
+ }
156
+ const textEncoder = new TextEncoder();
157
+ const textDecoder = new TextDecoder();
158
+ /**
159
+ * A run the host drives itself (docs/host.md "Host-driven runs"; the part 0179
160
+ * needs of 0175): `Sandbox.begin` runs a body to its first idle point, the host settles
161
+ * the open async calls in any order and advances it, and while it is idle it can be
162
+ * dumped (`dump()`) and continued elsewhere (`Sandbox.resume`). The async host functions
163
+ * are never called by the run: `calls` lists them and the host answers, so that a call
164
+ * can outlive the process that issued it. Sync host functions are called as in `run`.
165
+ */
166
+ export class Run {
167
+ /** "idle": waiting on `calls`; "done": `value` is the result; "threw": `error`. */
168
+ state = "idle";
169
+ value = undefined;
170
+ error = undefined;
171
+ #core;
172
+ /** Made by `Sandbox.begin` and `Sandbox.resume`. */
173
+ constructor(core) {
174
+ this.#core = core;
175
+ }
176
+ /** The async host calls waiting for a result, oldest first. */
177
+ get calls() {
178
+ return [...this.#core.open.values()];
179
+ }
180
+ /**
181
+ * Delivers the result of open call `id`. The VM applies it at the next `advance`, after
182
+ * the results settled before it (D1670).
183
+ */
184
+ settle(id, s) {
185
+ this.#core.settle(id, s);
186
+ }
187
+ /**
188
+ * Runs to the next idle point (or the end): applies the settled results, and calls the
189
+ * sync host functions the script calls meanwhile (awaiting them if they return
190
+ * promises). New async calls show up in `calls`.
191
+ */
192
+ async advance() {
193
+ if (this.state !== "idle")
194
+ throw new SandboxError("misuse", `the run is ${this.state}`);
195
+ this.end(await this.#core.advance());
196
+ }
197
+ /**
198
+ * The waiting run as a dump (a plain Script, D2830; docs/plain-js-dumps.md): its
199
+ * suspended activations, open calls and data as JavaScript, for `loadRun` in another
200
+ * sandbox or process. Only while idle, and only with `frozenBuiltins` (D2836). `states` are the host's states of open calls, by id (JSON
201
+ * data, D2770): the dump writes each with its call, and the load gives it back
202
+ * (`OpenCall.state`).
203
+ */
204
+ dump(states = {}) {
205
+ if (this.state !== "idle")
206
+ throw new SandboxError("misuse", `the run is ${this.state}: only an idle run can be dumped`);
207
+ return this.#core.dump(Object.entries(states).map(([id, v]) => [Number(id), stateJson(Number(id), v)]));
208
+ }
209
+ /** Records how a step ended. */
210
+ end(e) {
211
+ this.state = e.state;
212
+ if (e.state === "done")
213
+ this.value = e.value;
214
+ if (e.state === "threw")
215
+ this.error = e.error;
216
+ }
217
+ }
218
+ /**
219
+ * A run the sandbox drives (D2771): `Sandbox.start` begins one, `Sandbox.resume`
220
+ * continues one from a dump. It calls the async host functions as the script calls them
221
+ * and passes their results back, like `runAsync`; `result` is the outcome. While it waits
222
+ * on host calls it can be suspended (`suspend()`): the sandbox writes it as a dump, with
223
+ * each open call's state from its function's `save` hook, and stops. `Sandbox.resume`
224
+ * continues the dump in a new sandbox, any time later and in any process: each open call
225
+ * is continued by its function's `resume` hook (or made again, or rejected).
226
+ */
227
+ export class Task {
228
+ /** The sandbox the task runs in (dispose it when done). */
229
+ sandbox;
230
+ /**
231
+ * The run's result. Rejects with a SandboxError: `threw` for an exception, `suspended`
232
+ * when the task was suspended into a dump (this rejection counts as handled).
233
+ */
234
+ result;
235
+ #calls;
236
+ #request;
237
+ /** Made by `Sandbox.start` and `Sandbox.resume`. */
238
+ constructor(sandbox, result, calls, request) {
239
+ this.sandbox = sandbox;
240
+ this.result = result;
241
+ this.#calls = calls;
242
+ this.#request = request;
243
+ }
244
+ /** The async host calls the task waits for, oldest first. */
245
+ get calls() {
246
+ return [...this.#calls.values()].map((c) => c.call);
247
+ }
248
+ /**
249
+ * Suspends the task at its next idle point (no script work left, only host calls to
250
+ * wait for): calls `save` for every open call, dumps the run with those states and
251
+ * stops. The open calls' signals are aborted and their results ignored; `result`
252
+ * rejects with kind `suspended`. Resolves to the dump (a Script, for
253
+ * `Sandbox.resume`). Rejects, and the task goes on, when the run cannot be dumped (or a
254
+ * `save` throws); rejects when the run ends first.
255
+ */
256
+ suspend() {
257
+ return new Promise((resolve, reject) => this.#request({ stop: true, resolve, reject }));
258
+ }
259
+ /** `suspend` without stopping: a dump at the next idle point, and the task goes on. */
260
+ checkpoint() {
261
+ return new Promise((resolve, reject) => this.#request({ stop: false, resolve, reject }));
262
+ }
263
+ }
264
+ /** `drive`'s value at an idle point of a host-driven run. */
265
+ const IDLE = Symbol("idle");
266
+ export class Sandbox {
267
+ x;
268
+ module;
269
+ /** The VM; replaced when the dump module's sandbox takes another image (`takeImage`). */
270
+ vm;
271
+ opts;
272
+ /** Host function names by id - 1. */
273
+ fnNames = [];
274
+ fns = new Map();
275
+ /** Async host functions with hooks (D2771), by id. */
276
+ hooks = new Map();
277
+ atomCache = [];
278
+ rejections = [];
279
+ cons;
280
+ dead = null;
281
+ /** Deterministic mode: the VM's clock, moved only by async results (never backwards). */
282
+ clock;
283
+ /** The journal of the current run, being recorded or replayed (D1730). */
284
+ journal = null;
285
+ /** The first hook a dump's load could not serve (D3001). */
286
+ hookError = undefined;
287
+ /** Namespace objects this sandbox made for dotted function names (`tools`), as handles. */
288
+ namespaces = new Map();
289
+ /** The host function objects by id, as handles: one per identity (D2970). */
290
+ fnHandles = new Map();
291
+ fnAsync = new Map();
292
+ /** The globals the sandbox bound to host functions and namespaces (a dump's host globals). */
293
+ globalNames = new Set();
294
+ /** The host-driven run in progress (`begin`, `resume`), until it ends. */
295
+ active = null;
296
+ /** The task in progress (`start`, `resume`): ends it when the sandbox is disposed. */
297
+ cancelTask = null;
298
+ /**
299
+ * Where `create` and the constructor find the module when `opts.module` is not given:
300
+ * here `defaultWasmPath()`; each entry point of the package overrides it with its own
301
+ * module (D3106).
302
+ */
303
+ static defaultModule() {
304
+ return defaultWasmPath();
305
+ }
306
+ /** A new sandbox; loads (and caches) the default module unless `opts.module` is given. */
307
+ static async create(opts = {}) {
308
+ return new this({ ...opts, module: opts.module ?? (await loadModule(this.defaultModule())) });
309
+ }
310
+ /**
311
+ * Continues a task from its dump (`Task.suspend()`, or a host-driven `Run.dump()`) in
312
+ * a new sandbox made from `opts` (the same host functions, by name, as the one that
313
+ * dumped): `(await Sandbox.resume(dump, opts)).result`. See `resume`. A dump is code:
314
+ * resume only dumps you trust.
315
+ */
316
+ static async resume(dump, opts = {}) {
317
+ const sandbox = await this.create(opts);
318
+ try {
319
+ return sandbox.resume(dump);
320
+ }
321
+ catch (e) {
322
+ sandbox.dispose();
323
+ throw e;
324
+ }
325
+ }
326
+ /**
327
+ * Loads a dumped run into this sandbox (a fresh one) and drives it (D2771): each open
328
+ * call is continued by its function's `resume` hook with the state it was dumped with,
329
+ * made again (`call`) when the function has no `resume`, and rejected when it is a
330
+ * plain function (the sandbox cannot know whether calling it again is safe).
331
+ */
332
+ resume(dump) {
333
+ const { calls, result } = this.loadRunDump(dump);
334
+ return this.task(this.startRun(calls, result), calls);
335
+ }
336
+ /**
337
+ * Loads a dumped run into this sandbox (a fresh one) for the host to drive: the run is
338
+ * idle, waiting on the same open calls with the same ids (`run.calls`, with their
339
+ * states); the host settles them, issues them again or rejects them. No hooks run.
340
+ */
341
+ loadRun(dump) {
342
+ const { calls, result } = this.loadRunDump(dump);
343
+ return this.startRun(calls, result).run;
344
+ }
345
+ /** Loads a dump that holds a run: its open calls and the run's result promise. */
346
+ loadRunDump(dump) {
347
+ const { calls, handles } = this.loadDump(dump);
348
+ const result = handles.get("run") ?? 0;
349
+ this.guard(() => {
350
+ for (const [name, h] of handles)
351
+ if (name !== "run")
352
+ this.x.vm_handle_free(this.vm, h);
353
+ });
354
+ if (result === 0)
355
+ throw new SandboxError("misuse", "the dump holds no run (no handle named run)");
356
+ return { calls, result };
357
+ }
358
+ /**
359
+ * A new sandbox (its own wasm instance and VM), synchronously: needs a compiled module,
360
+ * `opts.module` or one `loadModule()` has finished loading. (`raw`: internal, a VM image
361
+ * of this process moving to the dump module, D2635.)
362
+ */
363
+ constructor(opts = {}, raw) {
364
+ let module = opts.module;
365
+ if (module === undefined) {
366
+ try {
367
+ module = compiled.get(String(new.target.defaultModule()));
368
+ }
369
+ catch {
370
+ // No default location: the error below.
371
+ }
372
+ }
373
+ if (module === undefined) {
374
+ throw new SandboxError("misuse", "no compiled module: pass opts.module or await loadModule() first");
375
+ }
376
+ const x = new WebAssembly.Instance(module, {}).exports;
377
+ if (x.djs_abi_version() !== ABI_VERSION) {
378
+ throw new SandboxError("misuse", `djs-wasm ABI ${x.djs_abi_version()}, expected ${ABI_VERSION}`);
379
+ }
380
+ let vm;
381
+ let restoredFns = [];
382
+ if (raw === undefined) {
383
+ // The module's base, decoded once by its first instance, not by each (D2252), and
384
+ // kept for later instances before `vm_new` takes this instance's bytes as its arena
385
+ // (D2060).
386
+ provideBase(module, x);
387
+ rememberBase(module, x);
388
+ if (opts.frozenBuiltins) {
389
+ if (!x.vm_new_with)
390
+ throw new SandboxError("misuse", "the djs-wasm module has no frozenBuiltins (vm_new_with)");
391
+ vm = x.vm_new_with(opts.memoryLimit ?? 0, 0, VM_FROZEN_BUILTINS);
392
+ }
393
+ else
394
+ vm = x.vm_new(opts.memoryLimit ?? 0);
395
+ if (vm === 0)
396
+ throw new SandboxError("memory", "memory limit too small for the realm");
397
+ }
398
+ else {
399
+ const image = raw.image;
400
+ restoredFns = raw.functions;
401
+ if (opts.deterministic)
402
+ this.clock = raw.clock ?? opts.deterministic.startedAt;
403
+ for (const [ns, h] of raw.namespaces)
404
+ this.namespaces.set(ns, h);
405
+ for (const [id, h] of raw.fnHandles)
406
+ this.fnHandles.set(id, h);
407
+ for (const g of raw.globals)
408
+ this.globalNames.add(g);
409
+ // May grow memory: take the buffer afterwards. The image's buffer becomes the arena,
410
+ // so it is staged with the arena's capacity (D1942).
411
+ const limit = opts.memoryLimit ?? 0;
412
+ const at = x.djs_image_staging ? x.djs_image_staging(image.length, limit) : x.djs_staging(image.length);
413
+ new Uint8Array(x.memory.buffer).set(image, at);
414
+ vm = x.vm_from_bytes(image.length, opts.memoryLimit ?? 0);
415
+ if (vm === 0)
416
+ throw new SandboxError("misuse", outText(x));
417
+ }
418
+ this.x = x;
419
+ this.module = module;
420
+ this.vm = vm;
421
+ this.opts = opts;
422
+ const cons = opts.console ?? globalThis.console;
423
+ this.cons = cons;
424
+ const builtins = {};
425
+ for (const m of CONSOLE_METHODS)
426
+ builtins[`console.${m}`] = (...a) => cons[m](...a);
427
+ if (raw !== undefined) {
428
+ // The VM already has its host functions; bind the ids to the given implementations.
429
+ restoredFns.forEach((name, i) => {
430
+ const a = name === "debugger" && opts.debugger !== undefined ? opts.debugger : opts.asyncFunctions?.[name];
431
+ const f = builtins[name] ?? opts.functions?.[name] ?? (typeof a === "object" ? a.call : a);
432
+ if (f === undefined)
433
+ throw new SandboxError("misuse", `the image needs host function ${name}`);
434
+ this.fnNames.push(name);
435
+ this.fns.set(i + 1, f);
436
+ if (typeof a === "object")
437
+ this.hooks.set(i + 1, a);
438
+ });
439
+ }
440
+ else {
441
+ // `console` is a namespace like `tools` (a host root of dumps, D2971).
442
+ for (const m of CONSOLE_METHODS)
443
+ this.addFunction(`console.${m}`, builtins[`console.${m}`], false);
444
+ for (const [name, f] of Object.entries(opts.functions ?? {}))
445
+ this.addFunction(name, f, false);
446
+ for (const [name, f] of Object.entries(opts.asyncFunctions ?? {}))
447
+ this.addFunction(name, f, true);
448
+ if (opts.debugger !== undefined) {
449
+ if (!x.vm_dump)
450
+ throw new SandboxError("misuse", "the debugger option needs a djs-wasm module with the dumper (the `dump` feature)");
451
+ this.addFunction("debugger", opts.debugger, true);
452
+ }
453
+ const globals = { ...(opts.globals ?? this.defaultGlobals()) };
454
+ if (opts.debugger !== undefined)
455
+ globals.debugger = "debugger";
456
+ for (const [name, identity] of Object.entries(globals))
457
+ this.bindGlobal(name, identity);
458
+ // What a new VM starts with; an image keeps its own (D1680).
459
+ const det = opts.deterministic;
460
+ const [lo, hi] = det ? seedWords(det.seed) : opts.randomSeed !== undefined ? seedWords(opts.randomSeed) : entropy();
461
+ x.vm_set_random_seed(vm, lo, hi);
462
+ const weak = opts.weakRefs ?? (det ? "strong" : "collected");
463
+ if (this.guard(() => x.vm_set_weak_refs(vm, WEAK_REF_MODES[weak])) !== 0) {
464
+ throw new SandboxError("misuse", `bad weakRefs mode ${String(weak)}`);
465
+ }
466
+ if (det) {
467
+ this.clock = det.startedAt;
468
+ x.vm_set_now(vm, det.startedAt);
469
+ x.vm_set_time_zone_offset(vm, 0);
470
+ }
471
+ }
472
+ }
473
+ /**
474
+ * Defines a host function with identity `name` and binds it as a global by the written
475
+ * rule (`SandboxOptions.globals`): `tools.read` as member `read` of the global namespace
476
+ * `tools`, an identifier as the global of that name, anything else nowhere.
477
+ */
478
+ defineFunction(name, f) {
479
+ this.addFunction(name, f, false);
480
+ this.bindDefault(name);
481
+ }
482
+ /** Defines an async host function (see `SandboxOptions.asyncFunctions`), bound as `defineFunction` does. */
483
+ defineAsyncFunction(name, f) {
484
+ this.addFunction(name, f, true);
485
+ this.bindDefault(name);
486
+ }
487
+ /** Makes host function `name` and, for `ns.member`, adds it to the namespace `ns`. */
488
+ addFunction(name, f, async) {
489
+ this.guard(() => {
490
+ const h = this.hostFunction(name, f, async);
491
+ const m = NAMESPACE_MEMBER.exec(name);
492
+ if (m)
493
+ this.defineOn(this.namespace(m[1]), m[2], h);
494
+ });
495
+ }
496
+ /** The namespace object `ns`, made on first use (D2632); bound nowhere by itself. */
497
+ namespace(ns) {
498
+ let o = this.namespaces.get(ns);
499
+ if (o === undefined) {
500
+ o = this.check(this.x.vm_new_object(this.vm), "memory");
501
+ this.namespaces.set(ns, o);
502
+ }
503
+ return o;
504
+ }
505
+ /** The written rule: the globals the sandbox binds without `SandboxOptions.globals`. */
506
+ defaultGlobals() {
507
+ const globals = {};
508
+ for (const name of this.fnNames) {
509
+ const m = NAMESPACE_MEMBER.exec(name);
510
+ if (m)
511
+ globals[m[1]] = m[1];
512
+ else if (IDENTIFIER.test(name))
513
+ globals[name] = name;
514
+ }
515
+ return globals;
516
+ }
517
+ bindDefault(name) {
518
+ const m = NAMESPACE_MEMBER.exec(name);
519
+ if (m)
520
+ this.bindGlobal(m[1], m[1]);
521
+ else if (IDENTIFIER.test(name))
522
+ this.bindGlobal(name, name);
523
+ }
524
+ /** The object of a namespace or host function by identity (D2970), as a handle the sandbox owns. */
525
+ hostValue(identity) {
526
+ return this.namespaces.get(identity) ?? this.fnHandles.get(this.fnNames.indexOf(identity) + 1);
527
+ }
528
+ bindGlobal(name, identity) {
529
+ const h = this.hostValue(identity);
530
+ if (h === undefined)
531
+ throw new SandboxError("misuse", `global ${name}: no host function or namespace ${identity}`);
532
+ this.guard(() => {
533
+ const g = this.x.vm_global(this.vm);
534
+ this.defineOn(g, name, h);
535
+ this.x.vm_handle_free(this.vm, g);
536
+ });
537
+ this.globalNames.add(name);
538
+ }
539
+ /** Defines a global variable holding a copy of `value` (sent through the wire format). */
540
+ setGlobal(name, value) {
541
+ this.guard(() => {
542
+ const h = this.toHandle(value);
543
+ const g = this.x.vm_global(this.vm);
544
+ this.defineOn(g, name, h);
545
+ this.x.vm_handle_free(this.vm, g);
546
+ if (h !== 0)
547
+ this.x.vm_handle_free(this.vm, h);
548
+ });
549
+ }
550
+ /**
551
+ * Runs `code` as a codemode function body (`return` gives the result) with the
552
+ * `params` as its parameters (sent through the wire format). `console` is a global. Host
553
+ * functions must return synchronously; use `runAsync` for ones that return promises. A
554
+ * promise as the result is unwrapped (fulfilled: its value; rejected: thrown).
555
+ */
556
+ run(code, params = {}, opts = {}) {
557
+ this.beginJournal(code, params, opts);
558
+ this.enter(code, params);
559
+ try {
560
+ // In sync mode the loop never yields: a promise from a host function is an error.
561
+ return this.auto(false).next().value;
562
+ }
563
+ catch (e) {
564
+ this.abandon(e);
565
+ throw e;
566
+ }
567
+ finally {
568
+ this.endRun();
569
+ this.journal = null;
570
+ }
571
+ }
572
+ /**
573
+ * Starts `code` as a codemode function body (as `run`) and runs it to its first idle
574
+ * point or its end, for the host to drive (`Run`): async host functions are not called
575
+ * but listed in `run.calls`. Sync host functions must return synchronously here (as in
576
+ * `run`); `advance` awaits them. A compile error throws; an exception in the script is
577
+ * `run.state === "threw"`. One run at a time: the sandbox runs nothing else until it
578
+ * ends.
579
+ */
580
+ begin(code, params = {}, opts = {}) {
581
+ this.beginJournal(code, params, opts);
582
+ this.enter(code, params, false, opts.filename);
583
+ const core = this.startRun([], 0);
584
+ try {
585
+ core.run.end(this.step(core, false).next().value);
586
+ }
587
+ catch (e) {
588
+ this.endRun();
589
+ this.abandon(e);
590
+ throw e;
591
+ }
592
+ if (core.run.state !== "idle")
593
+ this.endRun();
594
+ return core.run;
595
+ }
596
+ /**
597
+ * Starts `code` as a codemode function body (as `runAsync`) in a task the sandbox
598
+ * drives (D2771): it calls the async host functions as the script calls them, and
599
+ * `task.result` is the outcome. Unlike `runAsync`, the task can be suspended into a
600
+ * dump while it waits (`task.suspend()`) and continued later (`Sandbox.resume`). The
601
+ * first step runs now: a compile error throws. One task or run at a time.
602
+ */
603
+ start(code, params = {}, opts = {}) {
604
+ this.beginJournal(code, params, opts);
605
+ this.enter(code, params, false, opts.filename);
606
+ return this.task(this.startRun([], 0), []);
607
+ }
608
+ /**
609
+ * Drives `core` as a task: continues the `loaded` calls of a resumed run through their
610
+ * hooks, calls new async host functions as the script calls them, delivers results at
611
+ * the next slice, and serves `suspend`/`checkpoint` at idle points.
612
+ */
613
+ task(core, loaded) {
614
+ const calls = new Map();
615
+ const finished = [];
616
+ const requests = [];
617
+ let ended = false;
618
+ let wake = null;
619
+ const poke = () => {
620
+ const w = wake;
621
+ wake = null;
622
+ w?.();
623
+ };
624
+ const done = (id, ok, value) => {
625
+ if (ended || !calls.has(id))
626
+ return;
627
+ finished.push({ id, ok, value });
628
+ poke();
629
+ };
630
+ // Starts (or, after a load, continues) a call; its outcome arrives through `done`.
631
+ const begin = (open, loaded) => {
632
+ const fnId = this.fnNames.indexOf(open.fn) + 1;
633
+ const live = newCall(open.id, open.fn, open.args, open.state);
634
+ calls.set(open.id, live);
635
+ let r;
636
+ try {
637
+ const hooks = this.hooks.get(fnId);
638
+ if (!loaded)
639
+ r = this.invoke(fnId, open.args, live.call);
640
+ else if (hooks === undefined) {
641
+ throw new Error(`${open.fn}: the call was open when the run was dumped, and the host function has no hooks to continue it`);
642
+ }
643
+ else if (hooks.resume)
644
+ r = hooks.resume(open.state, live.call);
645
+ else
646
+ r = this.invoke(fnId, open.args, live.call);
647
+ }
648
+ catch (e) {
649
+ done(open.id, false, e);
650
+ return;
651
+ }
652
+ if (r instanceof Promise)
653
+ r.then((v) => done(open.id, true, v), (e) => done(open.id, false, e));
654
+ else
655
+ done(open.id, true, r);
656
+ };
657
+ core.onCall = (call) => begin(call, false);
658
+ core.beforeSlice = () => {
659
+ for (const { id, ok, value } of finished.splice(0)) {
660
+ calls.delete(id);
661
+ core.deliver(id, ok, value);
662
+ }
663
+ };
664
+ const stopWaiting = () => {
665
+ ended = true;
666
+ this.cancelTask = null;
667
+ for (const c of calls.values())
668
+ c.abort.abort();
669
+ for (const r of requests.splice(0))
670
+ r.reject(new SandboxError("misuse", "the task ended before an idle point"));
671
+ };
672
+ let settle;
673
+ const result = new Promise((resolve, reject) => (settle = { resolve, reject }));
674
+ const stop = (e) => {
675
+ if (ended)
676
+ return;
677
+ stopWaiting();
678
+ if (this.active === core)
679
+ this.endRun();
680
+ if (e.kind === "suspended")
681
+ result.catch(() => { });
682
+ settle.reject(e);
683
+ poke();
684
+ };
685
+ this.cancelTask = () => stop(new SandboxError("misuse", "the sandbox was disposed"));
686
+ // The dump at an idle point: each open call's state, from `save` or `call.state`.
687
+ const dump = () => {
688
+ const states = [];
689
+ for (const { call } of calls.values()) {
690
+ const save = this.hooks.get(this.fnNames.indexOf(call.fn) + 1)?.save;
691
+ const state = save ? save(call) : call.state;
692
+ if (state !== undefined)
693
+ states.push([call.id, stateJson(call.id, state)]);
694
+ }
695
+ return this.dumpRun(core, states);
696
+ };
697
+ const loop = async () => {
698
+ for (;;) {
699
+ const end = await core.advance();
700
+ if (ended)
701
+ return;
702
+ if (end.state !== "idle") {
703
+ stopWaiting();
704
+ if (end.state === "done")
705
+ settle.resolve(end.value);
706
+ else
707
+ settle.reject(end.error);
708
+ return;
709
+ }
710
+ // Idle: deliver what came in meanwhile, serve dump requests, or wait.
711
+ while (finished.length === 0 && !ended) {
712
+ const asked = requests.splice(0);
713
+ if (asked.length > 0) {
714
+ let text;
715
+ try {
716
+ text = dump();
717
+ }
718
+ catch (e) {
719
+ for (const r of asked)
720
+ r.reject(e);
721
+ continue;
722
+ }
723
+ for (const r of asked)
724
+ r.resolve(text);
725
+ if (asked.some((r) => r.stop)) {
726
+ stop(new SandboxError("suspended", "the task was suspended into a dump"));
727
+ return;
728
+ }
729
+ continue;
730
+ }
731
+ if (calls.size === 0) {
732
+ // Nothing to wait for (as in `runAsync`): the run is over without a result.
733
+ stopWaiting();
734
+ this.endRun();
735
+ settle.resolve(undefined);
736
+ return;
737
+ }
738
+ await new Promise((w) => (wake = w));
739
+ }
740
+ if (ended)
741
+ return;
742
+ }
743
+ };
744
+ for (const c of loaded)
745
+ begin(c, true);
746
+ const task = new Task(this, result, calls, (r) => {
747
+ if (ended)
748
+ r.reject(new SandboxError("misuse", "the task has ended"));
749
+ else {
750
+ requests.push(r);
751
+ poke();
752
+ }
753
+ });
754
+ loop().catch((e) => {
755
+ if (ended)
756
+ return;
757
+ stopWaiting();
758
+ settle.reject(e);
759
+ });
760
+ return task;
761
+ }
762
+ /** Makes a run, waiting on `calls`, with its result promise `result` (0: the entry call's). */
763
+ startRun(calls, result) {
764
+ const core = {
765
+ open: new Map(calls.map((c) => [c.id, c])),
766
+ result,
767
+ used: 0,
768
+ run: undefined,
769
+ advance: async () => {
770
+ try {
771
+ const it = this.step(core, true);
772
+ let r = it.next();
773
+ while (!r.done)
774
+ r = it.next(await r.value);
775
+ const end = r.value;
776
+ if (end.state !== "idle")
777
+ this.endRun();
778
+ return end;
779
+ }
780
+ catch (e) {
781
+ this.endRun();
782
+ this.abandon(e);
783
+ throw e;
784
+ }
785
+ },
786
+ settle: (id, s) => {
787
+ if (this.active !== core)
788
+ throw new SandboxError("misuse", "the run has ended");
789
+ if (!core.open.has(id))
790
+ throw new SandboxError("misuse", `no open call ${id}`);
791
+ core.deliver(id, s.ok, s.value);
792
+ },
793
+ deliver: (id, ok, value, at) => {
794
+ const call = core.open.get(id);
795
+ core.open.delete(id);
796
+ const now = this.complete(id, ok, value, true, at);
797
+ const j = this.journal;
798
+ if (j)
799
+ j.delivered++;
800
+ if (j?.mode === "record" && call) {
801
+ j.push({ type: "async", callId: id, fn: call.fn, args: call.args, ok, value: errorJson(ok, value), now: now });
802
+ }
803
+ },
804
+ dump: (states) => {
805
+ if (this.active !== core)
806
+ throw new SandboxError("misuse", "the run has ended");
807
+ return this.dumpRun(core, states);
808
+ },
809
+ };
810
+ core.run = new Run(core);
811
+ this.active = core;
812
+ return core;
813
+ }
814
+ /** One step of a host-driven run: `drive` until idle (recording a check) or the end. */
815
+ *step(core, async) {
816
+ try {
817
+ const r = yield* this.drive(async, core);
818
+ if (r !== IDLE)
819
+ return { state: "done", value: r };
820
+ this.recordCheck();
821
+ return { state: "idle" };
822
+ }
823
+ catch (e) {
824
+ if (e instanceof SandboxError && e.kind === "threw")
825
+ return { state: "threw", error: e };
826
+ throw e;
827
+ }
828
+ }
829
+ /**
830
+ * The run loop of `run`, `runAsync` and `replay`, on top of the host-driven run (`drive`
831
+ * to each idle point): calls the async host functions as soon as the script calls them
832
+ * (or, replaying, takes their results from the journal), passes their results back
833
+ * before the next fuel slice, and at an idle point waits for the next one. Yields a
834
+ * promise when the host must wait (only when `async`), returns the result.
835
+ */
836
+ *auto(async) {
837
+ const j = this.journal;
838
+ // Async host calls being served, and those that finished but are not passed back yet.
839
+ const inflight = new Map();
840
+ const finished = [];
841
+ const core = this.startRun([], 0);
842
+ core.beforeSlice = () => {
843
+ for (const { callId, ok, value } of finished.splice(0)) {
844
+ inflight.delete(callId);
845
+ core.deliver(callId, ok, value);
846
+ }
847
+ };
848
+ core.onCall = (call, fnId) => {
849
+ // Replay: the result comes from the journal, at an idle point.
850
+ if (j?.mode === "replay")
851
+ return;
852
+ const callId = call.id;
853
+ let p;
854
+ try {
855
+ const r = this.invoke(fnId, call.args);
856
+ if (!(r instanceof Promise)) {
857
+ finished.push({ callId, ok: true, value: r });
858
+ return;
859
+ }
860
+ if (!async)
861
+ throw new TypeError(`host function ${call.fn} returned a promise; use runAsync`);
862
+ p = r;
863
+ }
864
+ catch (e) {
865
+ finished.push({ callId, ok: false, value: e });
866
+ return;
867
+ }
868
+ inflight.set(callId, p.then((value) => void finished.push({ callId, ok: true, value }), (value) => void finished.push({ callId, ok: false, value })));
869
+ };
870
+ for (;;) {
871
+ const r = yield* this.drive(async, core);
872
+ if (r !== IDLE)
873
+ return r;
874
+ if (j?.mode === "record")
875
+ this.recordCheck();
876
+ else if (j && core.open.size > 0) {
877
+ // Replay: the checks recorded at this point, then the next result.
878
+ j.checkIdle(this.canonicalHash());
879
+ const e = j.take("async");
880
+ const call = core.open.get(e.callId);
881
+ j.expect(call !== undefined, () => `result ${j.delivered + 1} is for call ${e.callId}, which the script has not made`);
882
+ j.expectCall(e.fn, e.args, call.fn, call.args);
883
+ core.deliver(e.callId, e.ok, e.ok ? e.value : errorFromJson(e.value), e.now);
884
+ continue;
885
+ }
886
+ if (finished.length > 0)
887
+ continue;
888
+ if (inflight.size === 0)
889
+ return undefined; // nothing was called
890
+ // Only async host calls are left: wait for one of them.
891
+ yield Promise.race(inflight.values());
892
+ }
893
+ }
894
+ /** Records a `check` entry at an idle point, when recording. */
895
+ recordCheck() {
896
+ const j = this.journal;
897
+ if (j?.mode === "record")
898
+ j.push({ type: "check", delivered: j.delivered, issued: j.issued, hash: this.canonicalHash() });
899
+ }
900
+ /** The run has ended: frees its result handle; the sandbox can run again. */
901
+ endRun() {
902
+ const a = this.active;
903
+ this.active = null;
904
+ this.journal = null;
905
+ if (a !== null && a.result !== 0 && this.dead === null) {
906
+ this.x.vm_handle_free(this.vm, a.result);
907
+ a.result = 0;
908
+ }
909
+ }
910
+ /**
911
+ * `Run.dump()`: the waiting run as a dump, its result promise named "run" (handle
912
+ * 0: the entry call's outcome, which takes no handle, so a dump leaves the heap as it
913
+ * was and a journal's later checks still replay).
914
+ */
915
+ dumpRun(core, states = []) {
916
+ return this.dumpText([], [["run", core.result]], states);
917
+ }
918
+ /**
919
+ * The VM's global bindings (or the ones in `names`) as JavaScript that recreates them
920
+ * when `load`ed into another sandbox, also of a later djs (docs/functions-as-code.md):
921
+ * functions as their source text, closures and data as code, builtins by name. The
922
+ * host's functions and `console` are not written: the loading sandbox provides them
923
+ * under the same names. Needs `frozenBuiltins` (D2836). Throws a "misuse" error listing
924
+ * every value that cannot be written, by its path (a weak collection, a Proxy, ...).
925
+ */
926
+ dump(names = []) {
927
+ return this.dumpText(names, [], []);
928
+ }
929
+ /**
930
+ * `vm_dump`. staging: the globals the host defines and the names to dump (comma
931
+ * separated, a line each), then this sandbox's host function names (tab separated, id
932
+ * order: an open call or a host function held as a value is written by name), then
933
+ * the values to name for the host (`handle:name`, tab separated), then the host roots
934
+ * (the namespace objects, `handle:name`, written as `$djs.host(name)`, D2971), then the open calls'
935
+ * states (`id:bytes`, tab separated) followed by their JSON texts (D2770).
936
+ */
937
+ dumpText(names, handles, states) {
938
+ // Dumps need frozen builtins (D2836): the VM's mode, which a restored sandbox keeps
939
+ // from its image.
940
+ if (this.x.vm_frozen_builtins?.(this.vm) !== 1) {
941
+ throw new SandboxError("misuse", "cannot dump: dumps need frozen builtins (SandboxOptions.frozenBuiltins, D2836)");
942
+ }
943
+ const refs = !this.opts.inlineSources;
944
+ return this.viaDumper((sb) => sb.dumpHere(names, handles, states, refs));
945
+ }
946
+ /**
947
+ * Runs `f` on a sandbox with the dumper: this one, or (with `opts.dumpModule`, D2635)
948
+ * a transient sandbox in the dump module made from this VM's image. The image travels
949
+ * as is, so a dump leaves this VM untouched (D2633).
950
+ */
951
+ viaDumper(f) {
952
+ const dm = this.opts.dumpModule;
953
+ if (dm === undefined)
954
+ return f(this);
955
+ this.alive();
956
+ const raw = (sb) => ({
957
+ image: sb.arena().slice(),
958
+ functions: [...sb.fnNames],
959
+ clock: sb.clock,
960
+ namespaces: [...sb.namespaces],
961
+ fnHandles: [...sb.fnHandles],
962
+ globals: [...sb.globalNames],
963
+ });
964
+ // One dump-module sandbox per module and memory limit, kept between dumps (D2701,
965
+ // D2802): an instance costs a base, and its VM keeps what the dumper derives from the
966
+ // base on the first dump.
967
+ const limit = this.opts.memoryLimit ?? 0;
968
+ let byLimit = dumpers.get(dm);
969
+ if (byLimit === undefined)
970
+ dumpers.set(dm, (byLimit = new Map()));
971
+ let d = byLimit.get(limit);
972
+ if (d === undefined || d.dead !== null) {
973
+ d = new Sandbox({ ...this.opts, module: dm, dumpModule: undefined }, raw(this));
974
+ byLimit.set(limit, d);
975
+ }
976
+ else
977
+ d.takeImage(raw(this));
978
+ return f(d);
979
+ }
980
+ /** The dump module's sandbox: takes the main sandbox's image into the same VM. */
981
+ takeImage(raw) {
982
+ const x = this.x;
983
+ this.fnNames.splice(0, this.fnNames.length, ...raw.functions);
984
+ this.namespaces.clear();
985
+ for (const [ns, h] of raw.namespaces)
986
+ this.namespaces.set(ns, h);
987
+ this.fnHandles.clear();
988
+ for (const [id, h] of raw.fnHandles)
989
+ this.fnHandles.set(id, h);
990
+ this.globalNames.clear();
991
+ for (const g of raw.globals)
992
+ this.globalNames.add(g);
993
+ if (!x.vm_replace_image) {
994
+ this.replaceVm(raw);
995
+ return;
996
+ }
997
+ this.guard(() => {
998
+ const limit = this.opts.memoryLimit ?? 0;
999
+ const at = x.djs_image_staging ? x.djs_image_staging(raw.image.length, limit) : x.djs_staging(raw.image.length);
1000
+ new Uint8Array(x.memory.buffer).set(raw.image, at);
1001
+ if (x.vm_replace_image(this.vm, raw.image.length) !== 0) {
1002
+ throw new SandboxError("misuse", `the image does not load in the dump module: ${outText(x)}`);
1003
+ }
1004
+ });
1005
+ this.clock = raw.clock;
1006
+ this.atomCache.length = 0;
1007
+ }
1008
+ /** Replaces this sandbox's VM with an image (from the dump module). */
1009
+ replaceVm(raw) {
1010
+ const x = this.x;
1011
+ this.guard(() => {
1012
+ x.vm_free(this.vm);
1013
+ const limit = this.opts.memoryLimit ?? 0;
1014
+ const at = x.djs_image_staging ? x.djs_image_staging(raw.image.length, limit) : x.djs_staging(raw.image.length);
1015
+ new Uint8Array(x.memory.buffer).set(raw.image, at);
1016
+ const vm = x.vm_from_bytes(raw.image.length, limit);
1017
+ if (vm === 0) {
1018
+ this.dead = `lost its VM: ${outText(x)}`;
1019
+ throw new SandboxError("misuse", `the dump module's image does not load here: ${outText(x)}`);
1020
+ }
1021
+ this.vm = vm;
1022
+ });
1023
+ this.clock = raw.clock;
1024
+ this.atomCache.length = 0;
1025
+ }
1026
+ /**
1027
+ * A plain dump's `$djs.source(name, hash, params)` (D2882): the codemode body `name`
1028
+ * compiled again as this sandbox runs bodies, the one whose hash is the dump's (the
1029
+ * `sources` option's code first, then the scripts this process ran, newest first).
1030
+ */
1031
+ serveSource(callId, args) {
1032
+ const x = this.x;
1033
+ const vm = this.vm;
1034
+ const [name, hash, params] = args;
1035
+ const fail = (why) => this.failHook(callId, new Error(`source: ${why}`));
1036
+ if (typeof name !== "string" || typeof hash !== "string")
1037
+ return void fail("not a name and a hash");
1038
+ const candidates = [];
1039
+ const given = this.opts.sources?.(name);
1040
+ if (given !== undefined)
1041
+ candidates.push(given);
1042
+ for (const code of ranScripts.get(name) ?? [])
1043
+ if (code !== given)
1044
+ candidates.push(code);
1045
+ if (candidates.length === 0) {
1046
+ return void fail(`the dump continues the script ${JSON.stringify(name)}, which this host does not have (pass the sandbox option \`sources\`, or dump with \`inlineSources\`)`);
1047
+ }
1048
+ const o = this.opts;
1049
+ const goal = 1 | (o.typescript ? 2 : 0) | (o.experimentalDecorators || o.emitDecoratorMetadata ? 4 : 0) | (o.emitDecoratorMetadata ? 8 : 0) | (o.debugger ? 16 : 0);
1050
+ // The script's compile options as the dump's hash counts them (`CompileOptions::bits`).
1051
+ const bits = 1 | (goal & 2 ? 4 : 0) | (goal & 12 ? 8 : 0) | (goal & 8 ? 16 : 0) | (goal & 16 ? 64 : 0);
1052
+ const ps = typeof params === "string" ? params : "";
1053
+ const code = candidates.find((c) => scriptHash(c, ps, bits) === hash);
1054
+ if (code === undefined)
1055
+ return void fail(`the host's script ${JSON.stringify(name)} is not the one the dump refers to (another text, parameters or options)`);
1056
+ const n = textEncoder.encode(name);
1057
+ const head = textEncoder.encode(ps.split(",").filter((p) => p !== "").join("\n"));
1058
+ const src = textEncoder.encode(code);
1059
+ const all = new Uint8Array(n.length + head.length + src.length);
1060
+ all.set(n);
1061
+ all.set(head, n.length);
1062
+ all.set(src, n.length + head.length);
1063
+ const f = this.guard(() => {
1064
+ this.stage(all);
1065
+ return x.vm_compile_file(vm, goal, n.length, head.length, src.length);
1066
+ });
1067
+ if (f === 0)
1068
+ return void fail(outText(x));
1069
+ this.guard(() => {
1070
+ x.vm_resolve_host_call(vm, callId, 1, f);
1071
+ x.vm_handle_free(vm, f);
1072
+ });
1073
+ }
1074
+ /**
1075
+ * A plain dump's `$djs.host(name)` (D2971): the object this sandbox made for host
1076
+ * function `name` (its identity) or for the namespace `name` (`tools`, `console`).
1077
+ */
1078
+ serveHost(callId, args) {
1079
+ const [name] = args;
1080
+ const h = typeof name === "string" ? this.hostValue(name) : undefined;
1081
+ if (h === undefined)
1082
+ return void this.failHook(callId, new Error(`host: unknown host value ${String(name)}`));
1083
+ this.guard(() => this.x.vm_resolve_host_call(this.vm, callId, 1, h));
1084
+ }
1085
+ /**
1086
+ * A hook that cannot be served: its call throws, and the load fails once the Script ran
1087
+ * (a hook the dump calls inside a continuation, D3001, throws into its activation, not
1088
+ * the Script).
1089
+ */
1090
+ failHook(callId, e) {
1091
+ this.hookError ??= e;
1092
+ this.complete(callId, false, e, false);
1093
+ }
1094
+ /** `vm_dump` in this sandbox's own module. */
1095
+ dumpHere(names, handles, states, sourceRefs = true) {
1096
+ const x = this.x;
1097
+ x.vm_set_dump_source_refs?.(this.vm, sourceRefs ? 1 : 0);
1098
+ if (!x.vm_dump)
1099
+ throw new SandboxError("misuse", "the djs-wasm module has no dump (vm_dump): build it with the `dump` feature, or pass `dumpModule`");
1100
+ const host = this.globalNames;
1101
+ const text = [
1102
+ [...host].join(","),
1103
+ names.join(","),
1104
+ this.fnNames.join("\t"),
1105
+ handles.map(([name, h]) => `${h}:${name}`).join("\t"),
1106
+ [...this.namespaces].map(([name, h]) => `${h}:${name}`).join("\t"),
1107
+ states.map(([id, json]) => `${id}:${textEncoder.encode(json).length}`).join("\t"),
1108
+ states.map(([, json]) => json).join(""),
1109
+ ].join("\n");
1110
+ const r = this.guard(() => x.vm_dump(this.vm, this.stageText(text)));
1111
+ if (r !== 0)
1112
+ throw new SandboxError("misuse", `cannot dump:\n${outText(x)}`);
1113
+ return outText(x);
1114
+ }
1115
+ /**
1116
+ * Loads a dump (the text of `dump()`): runs it as a Script (D2890), so its bindings
1117
+ * become globals of this sandbox. A dump is code: load only dumps you trust, into a
1118
+ * fresh sandbox. Not recorded in a journal. Returns the open host calls the dump
1119
+ * recreated (its open calls name this sandbox's host functions); `Sandbox.resume`
1120
+ * continues a dumped run. The dumper is not needed.
1121
+ */
1122
+ load(dump) {
1123
+ const { calls, handles } = this.loadDump(dump);
1124
+ this.guard(() => {
1125
+ for (const h of handles.values())
1126
+ this.x.vm_handle_free(this.vm, h);
1127
+ });
1128
+ return { calls };
1129
+ }
1130
+ /** Loads a dump: the open calls it recreated and the handles it named (the caller frees them). */
1131
+ loadDump(dump) {
1132
+ if (this.active !== null)
1133
+ throw new SandboxError("misuse", "a run is in progress");
1134
+ const plain = plainDump(dump);
1135
+ if (!plain)
1136
+ throw new SandboxError("misuse", 'cannot load the dump: not a djs dump (no "djs-dump"; header)');
1137
+ return this.loadPlain(dump, plain.typescript);
1138
+ }
1139
+ /**
1140
+ * Loads a plain dump (D2830): runs it as a Script with the hooks in `$djs` (D2890), to
1141
+ * its first stop that is not a sync host call (idle, or done); the hooks are served only
1142
+ * meanwhile. Then the sandbox deletes `$djs` and the dump's `$dump` (D2916).
1143
+ * `$djs.resume(id, name, args, state)` opens the dump's call `id` of host function
1144
+ * `name` again under its id (`vm_open_call`): one of the calls returned, with its state;
1145
+ * `$djs.seed(lo, hi, next)` sets `Math.random`'s state and, once the dump has loaded,
1146
+ * the id of the next call (D2897). Other sync host functions are called
1147
+ * (they must return synchronously); async calls the Script makes are new calls, which
1148
+ * the run that follows reports. The Script's completion value, when a promise, is the
1149
+ * handle "run". The dumper is not needed: a plain dump is code.
1150
+ */
1151
+ loadPlain(dump, typescript) {
1152
+ const x = this.x;
1153
+ const vm = this.vm;
1154
+ if (!x.vm_open_call || !x.vm_dump_runtime) {
1155
+ throw new SandboxError("misuse", "the djs-wasm module cannot load plain dumps (no vm_open_call or vm_dump_runtime): rebuild it");
1156
+ }
1157
+ const dumpRuntime = x.vm_dump_runtime;
1158
+ const calls = [];
1159
+ this.guard(() => {
1160
+ // `$djs`: the dump runtime's helpers the dump names (D2903, 0215) with the hooks added.
1161
+ const hooks = this.check(dumpRuntime(vm, this.stageText(dumpRuntimeFor(dump))), "memory");
1162
+ for (const [name, id] of [["resume", HOOK_RESUME], ["seed", HOOK_SEED], ["source", HOOK_SOURCE], ["host", HOOK_HOST]]) {
1163
+ const f = this.check(x.vm_new_host_function(vm, id, this.stageText(name)), "memory");
1164
+ this.defineOn(hooks, name, f);
1165
+ x.vm_handle_free(vm, f);
1166
+ }
1167
+ const g = x.vm_global(vm);
1168
+ this.defineOn(g, PLAIN_HOOKS, hooks);
1169
+ x.vm_handle_free(vm, g);
1170
+ x.vm_handle_free(vm, hooks);
1171
+ });
1172
+ let st = STATUS_DONE;
1173
+ this.hookError = undefined;
1174
+ // `$djs.seed`'s next call id: the counter goes on from the dumped VM's (D2897).
1175
+ let next;
1176
+ this.enter(dump, {}, true, "dump.js", typescript);
1177
+ const slice = this.opts.fuelSlice ?? 1_000_000;
1178
+ const maxFuel = this.opts.maxFuel ?? Infinity;
1179
+ let used = 0;
1180
+ for (;;) {
1181
+ st = this.guard(() => {
1182
+ this.setClock();
1183
+ return x.vm_run(vm, slice);
1184
+ });
1185
+ used += x.vm_fuel_used ? x.vm_fuel_used(vm) : st === STATUS_OUT_OF_FUEL ? slice : 0;
1186
+ if (used >= maxFuel) {
1187
+ this.guard(() => x.vm_abort(vm));
1188
+ throw new SandboxError("fuel", `out of fuel after ${used} units while loading the dump`);
1189
+ }
1190
+ if (st === STATUS_OUT_OF_FUEL)
1191
+ continue;
1192
+ if (st !== STATUS_HOST_CALL)
1193
+ break;
1194
+ const { callId, fnId, args } = this.guard(() => this.readCall(x.vm_host_call(vm)));
1195
+ if (fnId === HOOK_RESUME) {
1196
+ const [id, name, callArgs, state] = args;
1197
+ const f = typeof name === "string" ? this.fnNames.indexOf(name) + 1 : 0;
1198
+ if (f === 0) {
1199
+ this.failHook(callId, new Error(`resume: unknown host function ${String(name)}`));
1200
+ continue;
1201
+ }
1202
+ if (this.guard(() => x.vm_open_call(vm, callId, f)) !== 0) {
1203
+ // (Out of memory may come after the parked call returned: then this is a no-op.)
1204
+ this.complete(callId, false, new Error(outText(x)), false);
1205
+ continue;
1206
+ }
1207
+ const call = { id: id, fn: name, args: Array.isArray(callArgs) ? callArgs : [] };
1208
+ if (state !== undefined)
1209
+ call.state = state;
1210
+ calls.push(call);
1211
+ continue;
1212
+ }
1213
+ if (fnId === HOOK_SOURCE) {
1214
+ this.serveSource(callId, args);
1215
+ continue;
1216
+ }
1217
+ if (fnId === HOOK_HOST) {
1218
+ this.serveHost(callId, args);
1219
+ continue;
1220
+ }
1221
+ let ok = true;
1222
+ let result;
1223
+ if (fnId === HOOK_SEED) {
1224
+ // `$djs.seed(lo, hi[, next])`, or `$djs.seed(next)` (D2837).
1225
+ const [lo, hi, n] = args;
1226
+ if (hi === undefined && typeof lo === "number")
1227
+ next = lo;
1228
+ else if (typeof lo === "number" && typeof hi === "number") {
1229
+ // (A state that ran, which the next dump writes again: `vm_restore_math_random`.)
1230
+ this.guard(() => (x.vm_restore_math_random ?? x.vm_set_math_random_seed)(vm, lo >>> 0, hi >>> 0));
1231
+ if (typeof n === "number")
1232
+ next = n;
1233
+ }
1234
+ else
1235
+ [ok, result] = [false, new TypeError("seed: not two int32 halves")];
1236
+ }
1237
+ else {
1238
+ try {
1239
+ result = this.invoke(fnId, args);
1240
+ if (result instanceof Promise)
1241
+ throw new TypeError(`host function ${this.fnNames[fnId - 1]} returned a promise while a dump loads`);
1242
+ }
1243
+ catch (e) {
1244
+ if (e instanceof SandboxError && e.kind === "trap")
1245
+ throw e;
1246
+ [ok, result] = [false, e];
1247
+ }
1248
+ }
1249
+ this.complete(callId, ok, result, false);
1250
+ }
1251
+ // (Without `next`: one past the highest reopened call, D2837.)
1252
+ if (x.vm_set_next_call_id) {
1253
+ const last = Math.max(0, ...calls.map((c) => c.id));
1254
+ this.guard(() => x.vm_set_next_call_id(vm, Math.max(next ?? 0, last + 1)));
1255
+ }
1256
+ // Set by hooks that ran meanwhile, which the narrowing of `this.hookError` cannot see.
1257
+ const hookError = this.hookError;
1258
+ this.hookError = undefined;
1259
+ return this.guard(() => {
1260
+ if (st === STATUS_THREW || st === STATUS_HOST_OUT_OF_MEMORY) {
1261
+ const h = x.vm_result(vm);
1262
+ const why = st === STATUS_THREW ? `the dump threw: ${this.display(h)}` : "out of host memory";
1263
+ x.vm_handle_free(vm, h);
1264
+ throw new SandboxError("misuse", `cannot load the dump: ${why}`);
1265
+ }
1266
+ if (hookError !== undefined)
1267
+ throw new SandboxError("misuse", `cannot load the dump: ${hookError.message}`);
1268
+ // The completion value: vm_result once done, else (idle) the entry call's outcome.
1269
+ const h = st === STATUS_DONE ? x.vm_result(vm) : x.vm_outcome(vm);
1270
+ const handles = new Map();
1271
+ const ps = h === 0 ? 0 : x.vm_promise_state(vm, h);
1272
+ if (ps >= 2)
1273
+ x.vm_handle_free(vm, outWords(x, 1)[0]);
1274
+ if (ps !== 0)
1275
+ handles.set("run", h);
1276
+ else if (h !== 0)
1277
+ x.vm_handle_free(vm, h);
1278
+ // The load is over: the host deletes the dump's globals (D2916), by running JS (the
1279
+ // module has no delete of its own).
1280
+ const drop = textEncoder.encode("delete globalThis.$djs; delete globalThis.$dump");
1281
+ this.stage(drop);
1282
+ const f = x.vm_compile_file(vm, 0, 0, 0, drop.length);
1283
+ if (f !== 0) {
1284
+ this.stage(new Uint8Array(0));
1285
+ if (x.vm_call(vm, f, 0, 0) === 0)
1286
+ x.vm_run(vm, slice);
1287
+ x.vm_handle_free(vm, f);
1288
+ }
1289
+ return { calls, handles };
1290
+ });
1291
+ }
1292
+ /**
1293
+ * Like `run`, but awaits host functions that return promises: a sync one keeps the VM
1294
+ * parked meanwhile, async ones run concurrently while the script goes on.
1295
+ */
1296
+ async runAsync(code, params = {}, opts = {}) {
1297
+ this.beginJournal(code, params, opts);
1298
+ this.enter(code, params);
1299
+ try {
1300
+ const it = this.auto(true);
1301
+ let r = it.next();
1302
+ while (!r.done)
1303
+ r = it.next(await r.value);
1304
+ return r.value;
1305
+ }
1306
+ catch (e) {
1307
+ this.abandon(e);
1308
+ throw e;
1309
+ }
1310
+ finally {
1311
+ this.endRun();
1312
+ this.journal = null;
1313
+ }
1314
+ }
1315
+ /**
1316
+ * Replays a recorded run (D1730): runs the journal's code and parameters, answers every
1317
+ * host call from the journal instead of calling the host functions (console output
1318
+ * still goes to the console), delivers the async results in the recorded order with
1319
+ * their clocks, and checks the calls the script makes and the canonical hashes at the
1320
+ * start, at every recorded idle point and at the end. The sandbox must be deterministic
1321
+ * and in the state the recording one was in (a fresh one created the same way, given the
1322
+ * same runs before); the fuel slice may differ.
1323
+ * Returns the run's result; a mismatch is a `diverged` SandboxError.
1324
+ */
1325
+ async replay(journal) {
1326
+ const head = journal[0];
1327
+ if (head?.type !== "run")
1328
+ throw new SandboxError("misuse", "a journal starts with a run entry");
1329
+ if (this.opts.deterministic === undefined)
1330
+ throw new SandboxError("misuse", "replay needs a deterministic sandbox");
1331
+ const j = new Journal("replay", journal);
1332
+ j.expect(head.engine === this.buildHash(), () => `engine ${this.buildHash()}, the journal was made by ${head.engine}`);
1333
+ j.expect(head.hash === this.canonicalHash(), () => "the sandbox's state before the run differs from the recording's");
1334
+ j.next = 1;
1335
+ this.enter(head.code, head.params);
1336
+ this.journal = j;
1337
+ try {
1338
+ const it = this.auto(true);
1339
+ let r = it.next();
1340
+ while (!r.done)
1341
+ r = it.next(await r.value);
1342
+ return r.value;
1343
+ }
1344
+ catch (e) {
1345
+ this.abandon(e);
1346
+ throw e;
1347
+ }
1348
+ finally {
1349
+ this.endRun();
1350
+ this.journal = null;
1351
+ }
1352
+ }
1353
+ /** The VM's canonical hash (D1730) as 16 hex digits: its state, independent of collections. */
1354
+ canonicalHash() {
1355
+ return this.guard(() => {
1356
+ if (this.x.vm_canonical_hash(this.vm) !== 0)
1357
+ throw new SandboxError("memory", "no memory for the canonical hash");
1358
+ const [lo, hi] = outWords(this.x, 2);
1359
+ return hi.toString(16).padStart(8, "0") + lo.toString(16).padStart(8, "0");
1360
+ });
1361
+ }
1362
+ buildHash() {
1363
+ return (this.x.djs_build_hash() >>> 0).toString(16).padStart(8, "0");
1364
+ }
1365
+ /** Starts recording a run into `opts.journal`, if given. */
1366
+ beginJournal(code, params, opts) {
1367
+ if (this.active !== null)
1368
+ throw new SandboxError("misuse", "a run is in progress");
1369
+ this.journal = null;
1370
+ if (opts.journal === undefined)
1371
+ return;
1372
+ if (this.opts.deterministic === undefined)
1373
+ throw new SandboxError("misuse", "recording needs a deterministic sandbox");
1374
+ opts.journal.push({ type: "run", engine: this.buildHash(), code, params, hash: this.canonicalHash() });
1375
+ this.journal = new Journal("record", opts.journal);
1376
+ }
1377
+ /**
1378
+ * After a failed run: drops the VM's call if it is still in one (out of fuel, or a host
1379
+ * side error escaped the loop), so the next run starts clean. Not after a trap: the
1380
+ * instance is gone then.
1381
+ */
1382
+ abandon(e) {
1383
+ if (this.dead === null && !(e instanceof SandboxError && e.kind === "trap")) {
1384
+ this.guard(() => this.x.vm_abort(this.vm));
1385
+ }
1386
+ }
1387
+ /**
1388
+ * The VM's arena: a view into wasm memory, valid until the next call into the sandbox
1389
+ * (the arena may move when it grows). For inspecting the heap with `LAYOUT` (the docs'
1390
+ * live examples walk it cell by cell).
1391
+ */
1392
+ heapView() {
1393
+ this.alive();
1394
+ return this.arena();
1395
+ }
1396
+ /**
1397
+ * Where the run waits on open host call `id` (D3054): the `await` (or `yield`) of the
1398
+ * innermost suspended activation waiting on the call, directly or through promise
1399
+ * combinators. `file` is the name the code was compiled as: the run's `filename` for
1400
+ * the code it started with, the dump loader's name (`dump.js`) for code a dump carried
1401
+ * (a run continued from a dump runs the dump's text). Undefined when the call is not
1402
+ * open or nothing waits on it at a known position, and in modules without the dumper
1403
+ * (the `dump` feature), where it is not built.
1404
+ */
1405
+ callSite(id) {
1406
+ this.alive();
1407
+ const x = this.x;
1408
+ if (!x.vm_call_site)
1409
+ return undefined;
1410
+ const n = this.guard(() => x.vm_call_site(this.vm, id));
1411
+ if (n === 0)
1412
+ return undefined;
1413
+ const m = /^(\d+):(\d+):(.*)$/s.exec(outText(x));
1414
+ return m ? { file: m[3], line: Number(m[1]), column: Number(m[2]) } : undefined;
1415
+ }
1416
+ /** Collects garbage now. False if the heap could not reserve the to-space. */
1417
+ collect() {
1418
+ return this.guard(() => this.x.vm_collect(this.vm) === 0);
1419
+ }
1420
+ /** The arena's current size in bytes. */
1421
+ get heapSize() {
1422
+ this.alive();
1423
+ return this.x.vm_arena_len(this.vm);
1424
+ }
1425
+ /** Releases the VM. The sandbox cannot be used afterwards. A task in progress ends (`misuse`). */
1426
+ dispose() {
1427
+ this.cancelTask?.();
1428
+ this.active = null;
1429
+ if (this.dead === null) {
1430
+ this.x.vm_free(this.vm);
1431
+ this.dead = "disposed";
1432
+ }
1433
+ }
1434
+ /** Panics inside the module on purpose (tests the trap path). */
1435
+ debugTrap() {
1436
+ this.guard(() => this.x.djs_debug_panic());
1437
+ }
1438
+ // ---- internals ---------------------------------------------------------------------
1439
+ alive() {
1440
+ if (this.dead !== null)
1441
+ throw new SandboxError("misuse", `sandbox is unusable: ${this.dead}`);
1442
+ }
1443
+ /** Runs `f` and turns a wasm trap into a SandboxError; the sandbox is dead afterwards. */
1444
+ guard(f) {
1445
+ this.alive();
1446
+ try {
1447
+ return f();
1448
+ }
1449
+ catch (e) {
1450
+ if (e instanceof WebAssembly.RuntimeError) {
1451
+ const msg = trapText(this.x, e);
1452
+ this.dead = `trapped (${msg})`;
1453
+ throw new SandboxError("trap", `djs-wasm trapped: ${msg}`);
1454
+ }
1455
+ throw e;
1456
+ }
1457
+ }
1458
+ check(h, kind) {
1459
+ if (h === 0)
1460
+ throw new SandboxError(kind, outText(this.x) || "out of memory");
1461
+ return h;
1462
+ }
1463
+ /** Copies bytes into the staging buffer. */
1464
+ stage(bytes) {
1465
+ const at = this.x.djs_staging(bytes.length);
1466
+ new Uint8Array(this.x.memory.buffer).set(bytes, at);
1467
+ }
1468
+ stageText(s) {
1469
+ const b = textEncoder.encode(s);
1470
+ this.stage(b);
1471
+ return b.length;
1472
+ }
1473
+ hostFunction(name, f, async = false) {
1474
+ let id = this.fnNames.indexOf(name) + 1;
1475
+ if (id === 0) {
1476
+ this.fnNames.push(name);
1477
+ id = this.fnNames.length;
1478
+ }
1479
+ if (typeof f === "function") {
1480
+ this.fns.set(id, f);
1481
+ this.hooks.delete(id);
1482
+ }
1483
+ else {
1484
+ if (!async || typeof f?.call !== "function")
1485
+ throw new SandboxError("misuse", `host function ${name}: not a function or { call, save, resume }`);
1486
+ this.fns.set(id, f.call);
1487
+ this.hooks.set(id, f);
1488
+ }
1489
+ // One function object per identity (D2970): a dump's `$djs.host(name)` gets the one
1490
+ // the sandbox bound. A redefinition makes a new one only if its kind changes.
1491
+ const old = this.fnHandles.get(id);
1492
+ if (old !== undefined && this.fnAsync.get(id) === async)
1493
+ return old;
1494
+ const shortName = name.slice(name.lastIndexOf(".") + 1);
1495
+ const len = this.stageText(shortName);
1496
+ const h = this.check(async ? this.x.vm_new_async_host_function(this.vm, id, len) : this.x.vm_new_host_function(this.vm, id, len), "memory");
1497
+ if (old !== undefined)
1498
+ this.x.vm_handle_free(this.vm, old);
1499
+ this.fnHandles.set(id, h);
1500
+ this.fnAsync.set(id, async);
1501
+ return h;
1502
+ }
1503
+ defineOn(obj, name, value) {
1504
+ if (this.x.vm_define(this.vm, obj, this.stageText(name), value) !== 0) {
1505
+ throw new SandboxError("memory", `cannot define ${name}`);
1506
+ }
1507
+ }
1508
+ /** A handle for a host value (0 for undefined). */
1509
+ toHandle(v) {
1510
+ if (v === undefined)
1511
+ return 0;
1512
+ const bytes = encode(v);
1513
+ if (typeof v === "string" && v.length >= IN_PLACE_MIN) {
1514
+ const h = stringInPlace(this.x, this.vm, v, bytes);
1515
+ if (h !== 0)
1516
+ return h;
1517
+ }
1518
+ this.stage(bytes);
1519
+ return this.check(this.x.vm_decode_wire(this.vm, bytes.length), "memory");
1520
+ }
1521
+ arena() {
1522
+ const x = this.x;
1523
+ return new Uint8Array(x.memory.buffer, x.vm_arena_ptr(this.vm), x.vm_arena_len(this.vm));
1524
+ }
1525
+ read(lo, hi) {
1526
+ return readValue(this.arena(), [lo, hi], { atomCache: this.atomCache });
1527
+ }
1528
+ readHandle(h) {
1529
+ this.x.vm_handle_bits(this.vm, h);
1530
+ const w = outWords(this.x, 2);
1531
+ return this.read(w[0], w[1]);
1532
+ }
1533
+ display(h) {
1534
+ const n = this.x.vm_display(this.vm, h);
1535
+ return textDecoder.decode(new Uint8Array(this.x.memory.buffer, this.x.djs_out_ptr(), n));
1536
+ }
1537
+ /** Compiles `code` and starts the call; returns nothing (the VM holds the call). */
1538
+ enter(code, params, script = false, filename, typescript = false) {
1539
+ if (this.active !== null)
1540
+ throw new SandboxError("misuse", "a run is in progress");
1541
+ this.guard(() => {
1542
+ const x = this.x;
1543
+ const vm = this.vm;
1544
+ // A body's parameters: the run's data, then the sandbox's bindings (D2972), whose
1545
+ // objects the sandbox owns.
1546
+ const bound = script ? [] : Object.entries(this.opts.bindings ?? {});
1547
+ const boundHandles = bound.map(([p, identity]) => {
1548
+ if (Object.hasOwn(params, p))
1549
+ throw new SandboxError("misuse", `parameter ${p} is both a run parameter and a binding`);
1550
+ const h = this.hostValue(identity);
1551
+ if (h === undefined)
1552
+ throw new SandboxError("misuse", `binding ${p}: no host function or namespace ${identity}`);
1553
+ return h;
1554
+ });
1555
+ const names = [...Object.keys(params), ...bound.map(([p]) => p)];
1556
+ const handles = Object.values(params).map((v) => this.toHandle(v));
1557
+ const name = textEncoder.encode(filename ?? "");
1558
+ const head = textEncoder.encode(names.join("\n"));
1559
+ const src = textEncoder.encode(code);
1560
+ const all = new Uint8Array(name.length + head.length + src.length);
1561
+ all.set(name);
1562
+ all.set(head, name.length);
1563
+ all.set(src, name.length + head.length);
1564
+ this.stage(all);
1565
+ const o = this.opts;
1566
+ const goal = (o.debugger ? 16 : 0) |
1567
+ (script
1568
+ ? typescript ? 2 : 0
1569
+ : 1 |
1570
+ (o.typescript ? 2 : 0) |
1571
+ (o.experimentalDecorators || o.emitDecoratorMetadata ? 4 : 0) |
1572
+ (o.emitDecoratorMetadata ? 8 : 0));
1573
+ const f = x.vm_compile_file(vm, goal, name.length, head.length, src.length);
1574
+ if (f !== 0 && !script)
1575
+ rememberScript(filename || "sandbox.js", code);
1576
+ if (f === 0) {
1577
+ const msg = outText(x);
1578
+ for (const h of handles)
1579
+ if (h !== 0)
1580
+ x.vm_handle_free(vm, h);
1581
+ throw new SandboxError(msg.startsWith("SyntaxError") ? "syntax" : "memory", msg);
1582
+ }
1583
+ const argv = [...handles, ...boundHandles];
1584
+ const args = new Uint8Array(4 * argv.length);
1585
+ const dv = new DataView(args.buffer);
1586
+ argv.forEach((h, i) => dv.setUint32(4 * i, h, true));
1587
+ this.stage(args);
1588
+ const r = x.vm_call(vm, f, 0, argv.length);
1589
+ x.vm_handle_free(vm, f);
1590
+ for (const h of handles)
1591
+ if (h !== 0)
1592
+ x.vm_handle_free(vm, h);
1593
+ if (r !== 0)
1594
+ throw new SandboxError("misuse", "a call is already in progress");
1595
+ });
1596
+ }
1597
+ /**
1598
+ * The run loop to the next idle point or the end, as a generator: yields a promise when
1599
+ * the host must wait for a sync host function (only when `async`), returns the result,
1600
+ * or `IDLE` when only async host calls are left (they are in `run.open`).
1601
+ */
1602
+ *drive(async, run) {
1603
+ const x = this.x;
1604
+ const vm = this.vm;
1605
+ const slice = this.opts.fuelSlice ?? 1_000_000;
1606
+ const maxFuel = this.opts.maxFuel ?? Infinity;
1607
+ const j = this.journal;
1608
+ for (;;) {
1609
+ run.beforeSlice?.();
1610
+ const st = this.guard(() => {
1611
+ this.setClock();
1612
+ return x.vm_run(vm, slice);
1613
+ });
1614
+ // Counted across sync host calls too, which end a slice early (D2653, D2702).
1615
+ run.used += x.vm_fuel_used ? x.vm_fuel_used(vm) : st === STATUS_OUT_OF_FUEL ? slice : 0;
1616
+ for (const callId of this.newHostCalls()) {
1617
+ const { fnId, args } = this.callInfo(callId);
1618
+ const call = { id: callId, fn: this.fnNames[fnId - 1], args };
1619
+ run.open.set(callId, call);
1620
+ if (j)
1621
+ j.issued++;
1622
+ run.onCall?.(call, fnId);
1623
+ }
1624
+ if (st === STATUS_DONE || st === STATUS_THREW) {
1625
+ const outcome = this.guard(() => {
1626
+ let h = x.vm_result(vm);
1627
+ if (run.result !== 0) {
1628
+ // A resumed run's result promise: its entry call was the dump's Script.
1629
+ x.vm_handle_free(vm, h);
1630
+ h = run.result;
1631
+ run.result = 0;
1632
+ }
1633
+ let threw = st === STATUS_THREW;
1634
+ // A promise result (an async body): its outcome.
1635
+ const ps = x.vm_promise_state(vm, h);
1636
+ if (ps === 1) {
1637
+ x.vm_handle_free(vm, h);
1638
+ throw new SandboxError("threw", "the result is a promise that never settles");
1639
+ }
1640
+ if (ps >= 2) {
1641
+ const r = outWords(x, 1)[0];
1642
+ x.vm_handle_free(vm, h);
1643
+ h = r;
1644
+ threw = ps === 3;
1645
+ }
1646
+ const value = this.readHandle(h);
1647
+ const message = threw ? this.display(h) : "";
1648
+ x.vm_handle_free(vm, h);
1649
+ return { value, message, threw };
1650
+ });
1651
+ const { value, message, threw } = outcome;
1652
+ if (j) {
1653
+ const end = { type: "end", ok: !threw, value: threw ? message : value };
1654
+ j.finish(end, this.canonicalHash());
1655
+ }
1656
+ if (threw)
1657
+ throw new SandboxError("threw", message, value);
1658
+ this.checkRejections();
1659
+ return value;
1660
+ }
1661
+ if (st === STATUS_OUT_OF_FUEL) {
1662
+ if (run.used >= maxFuel) {
1663
+ // `run` abandons the call (vm_abort), so the sandbox stays usable.
1664
+ throw new SandboxError("fuel", `out of fuel after ${run.used} units`);
1665
+ }
1666
+ continue;
1667
+ }
1668
+ if (st === STATUS_HOST_OUT_OF_MEMORY) {
1669
+ // The host (wasm memory) could not back the heap below its cap: not a script
1670
+ // error. The VM abandoned the call and is usable again (D1690).
1671
+ throw new SandboxError("memory", "out of host memory");
1672
+ }
1673
+ if (st === STATUS_IDLE) {
1674
+ this.checkRejections();
1675
+ return IDLE;
1676
+ }
1677
+ if (st !== STATUS_HOST_CALL)
1678
+ return undefined;
1679
+ if (run.used >= maxFuel)
1680
+ throw new SandboxError("fuel", `out of fuel after ${run.used} units`);
1681
+ const { callId, fnId, args } = this.guard(() => {
1682
+ const argc = x.vm_host_call(vm);
1683
+ return this.readCall(argc);
1684
+ });
1685
+ let ok = true;
1686
+ let result;
1687
+ const fn = this.fnNames[fnId - 1];
1688
+ const journaled = j !== null && !fn?.startsWith("console.");
1689
+ try {
1690
+ if (journaled && j.mode === "replay") {
1691
+ j.issued++;
1692
+ const e = j.take("sync");
1693
+ j.expectCall(e.fn, e.args, fn, args);
1694
+ if (!e.ok)
1695
+ throw errorFromJson(e.value);
1696
+ result = e.value;
1697
+ }
1698
+ else {
1699
+ result = this.invoke(fnId, args);
1700
+ if (result instanceof Promise) {
1701
+ if (!async)
1702
+ throw new TypeError(`host function ${fn} returned a promise; use runAsync`);
1703
+ result = yield result;
1704
+ }
1705
+ }
1706
+ }
1707
+ catch (e) {
1708
+ if (e instanceof SandboxError && (e.kind === "trap" || e.kind === "diverged"))
1709
+ throw e;
1710
+ ok = false;
1711
+ result = e;
1712
+ }
1713
+ if (journaled && j.mode === "record") {
1714
+ j.issued++;
1715
+ j.push({ type: "sync", fn, args, ok, value: errorJson(ok, result) });
1716
+ }
1717
+ this.complete(callId, ok, result, false);
1718
+ }
1719
+ }
1720
+ /**
1721
+ * The unhandled rejections collected so far (`unhandledRejections: "collect"`), as host
1722
+ * values; forgets them.
1723
+ */
1724
+ takeUnhandledRejections() {
1725
+ return this.rejections.splice(0);
1726
+ }
1727
+ /** Reports the VM's unhandled rejections per `opts.unhandledRejections` (D1423). */
1728
+ checkRejections() {
1729
+ const reasons = this.guard(() => {
1730
+ const x = this.x;
1731
+ const n = x.vm_take_unhandled_rejections(this.vm);
1732
+ const handles = outWords(x, n);
1733
+ return handles.map((h) => {
1734
+ const value = this.readHandle(h);
1735
+ const message = this.display(h);
1736
+ x.vm_handle_free(this.vm, h);
1737
+ return { value, message };
1738
+ });
1739
+ });
1740
+ if (reasons.length === 0)
1741
+ return;
1742
+ const mode = this.opts.unhandledRejections ?? "warn";
1743
+ if (mode === "throw") {
1744
+ const first = reasons[0];
1745
+ throw new SandboxError("threw", `Possibly unhandled promise rejection: ${first.message}`, first.value);
1746
+ }
1747
+ for (const r of reasons) {
1748
+ if (mode === "collect")
1749
+ this.rejections.push(r.value);
1750
+ else
1751
+ this.cons.warn("Possibly unhandled promise rejection:", r.message);
1752
+ }
1753
+ }
1754
+ /**
1755
+ * Passes the host clock and time zone to the VM (before every slice). Not in
1756
+ * deterministic mode: there the clock moves only with async results (`complete`).
1757
+ */
1758
+ setClock() {
1759
+ if (this.clock !== undefined)
1760
+ return;
1761
+ const { now, timeZoneOffset } = this.opts;
1762
+ const ms = typeof now === "number" ? now : (now ?? Date.now)();
1763
+ this.x.vm_set_now(this.vm, ms);
1764
+ this.x.vm_set_time_zone_offset(this.vm, timeZoneOffset ?? -new Date(ms).getTimezoneOffset());
1765
+ }
1766
+ /** Calls host function `fnId` with arguments read from the VM; `call` is `this` for hooks. */
1767
+ invoke(fnId, args, call) {
1768
+ const f = this.fns.get(fnId);
1769
+ if (f === undefined)
1770
+ throw new TypeError(`unknown host function ${fnId}`);
1771
+ if (this.hooks.has(fnId))
1772
+ return f.apply(call ?? newCall(fnId, this.fnNames[fnId - 1], args).call, args);
1773
+ const isConsole = this.fnNames[fnId - 1]?.startsWith("console.");
1774
+ return f(...(isConsole ? args.map(consoleArg) : args));
1775
+ }
1776
+ /** The async host calls made since the last check (`vm_new_host_calls`). */
1777
+ newHostCalls() {
1778
+ return this.guard(() => {
1779
+ const n = this.x.vm_new_host_calls(this.vm);
1780
+ return outWords(this.x, n);
1781
+ });
1782
+ }
1783
+ /** The function and arguments of pending host call `callId`. */
1784
+ callInfo(callId) {
1785
+ return this.guard(() => this.readCall(this.x.vm_host_call_info(this.vm, callId)));
1786
+ }
1787
+ /** Reads `[call_id, fn_id, this, args...]` from the out buffer. */
1788
+ readCall(argc) {
1789
+ const x = this.x;
1790
+ const w = outWords(x, 4 + 2 * argc);
1791
+ const args = [];
1792
+ for (let i = 0; i < argc; i++)
1793
+ args.push(this.read(w[4 + 2 * i], w[5 + 2 * i]));
1794
+ return { callId: w[0], fnId: w[1], args };
1795
+ }
1796
+ /**
1797
+ * Passes a host call's outcome back: a value (through the wire format), or an error
1798
+ * thrown at the call (sync) or rejecting its promise (async), as an Error of the same
1799
+ * kind with the host error's message. A value the wire format cannot carry becomes
1800
+ * such an error too.
1801
+ */
1802
+ complete(callId, ok, value, async, at) {
1803
+ const x = this.x;
1804
+ const vm = this.vm;
1805
+ let h = 0;
1806
+ if (ok) {
1807
+ try {
1808
+ h = this.guard(() => this.toHandle(value));
1809
+ }
1810
+ catch (e) {
1811
+ if (e instanceof SandboxError && e.kind === "trap")
1812
+ throw e;
1813
+ ok = false;
1814
+ value = e;
1815
+ }
1816
+ }
1817
+ if (!ok) {
1818
+ const e = value;
1819
+ h = this.guard(() => {
1820
+ const msg = this.stageText(e instanceof Error ? e.message : String(e));
1821
+ const kind = e instanceof TypeError ? 1 : e instanceof RangeError ? 2 : 0;
1822
+ return this.check(x.vm_new_error(vm, kind, msg), "memory");
1823
+ });
1824
+ }
1825
+ return this.guard(() => {
1826
+ let clock;
1827
+ if (async && this.clock !== undefined) {
1828
+ // Deterministic mode: the result's time (a replay's: the recorded one), applied
1829
+ // when the VM takes the result (D1670).
1830
+ const { now } = this.opts;
1831
+ this.clock = at ?? Math.max(this.clock, typeof now === "number" ? now : (now ?? Date.now)());
1832
+ clock = this.clock;
1833
+ x.vm_resolve_host_call_at(vm, callId, ok ? 1 : 0, h, this.clock);
1834
+ }
1835
+ else {
1836
+ x.vm_resolve_host_call(vm, callId, ok ? 1 : 0, h);
1837
+ }
1838
+ if (h !== 0)
1839
+ x.vm_handle_free(vm, h);
1840
+ return clock;
1841
+ });
1842
+ }
1843
+ }
1844
+ const WEAK_REF_MODES = { collected: 0, strong: 1, absent: 2 };
1845
+ /** A call's hook view (`this` of `call`, the argument of `save` and `resume`) and its abort. */
1846
+ function newCall(id, fn, args, state) {
1847
+ const abort = new AbortController();
1848
+ return { call: { id, fn, args, startedAt: Date.now(), state, signal: abort.signal }, abort };
1849
+ }
1850
+ /** A call's state as the JSON text a dump carries (D2770). */
1851
+ function stateJson(id, state) {
1852
+ let json;
1853
+ try {
1854
+ json = JSON.stringify(state);
1855
+ }
1856
+ catch (e) {
1857
+ throw new SandboxError("misuse", `the state of call ${id} is not JSON data: ${String(e)}`);
1858
+ }
1859
+ if (json === undefined)
1860
+ throw new SandboxError("misuse", `the state of call ${id} is not JSON data`);
1861
+ return json;
1862
+ }
1863
+ /** The journal of a run being recorded or replayed (D1730). */
1864
+ class Journal {
1865
+ /** Replay: the index of the next entry. */
1866
+ next = 0;
1867
+ /** Async results delivered, non-console host calls made. */
1868
+ delivered = 0;
1869
+ issued = 0;
1870
+ mode;
1871
+ entries;
1872
+ constructor(mode, entries) {
1873
+ this.mode = mode;
1874
+ this.entries = entries;
1875
+ }
1876
+ push(e) {
1877
+ this.entries.push(e);
1878
+ }
1879
+ expect(ok, why) {
1880
+ if (!ok)
1881
+ throw new SandboxError("diverged", `replay diverged: ${why()}`);
1882
+ }
1883
+ /** Replay: takes the next entry, which must be of `type`. */
1884
+ take(type) {
1885
+ const e = this.entries[this.next];
1886
+ this.expect(e?.type === type, () => `the script needs ${type === "sync" ? "a host call" : "a result"} here, the journal has ${e ? `a ${e.type} entry` : "nothing more"} (entry ${this.next})`);
1887
+ this.next++;
1888
+ return e;
1889
+ }
1890
+ expectCall(fn, args, gotFn, gotArgs) {
1891
+ this.expect(fn === gotFn && sameValue(args, gotArgs), () => `the script called ${gotFn}(${stable(gotArgs)}), the recording ${fn}(${stable(args)})`);
1892
+ }
1893
+ /** Replay at an idle point: the recorded checks for this many delivered results. */
1894
+ checkIdle(hash) {
1895
+ for (let e = this.entries[this.next]; e?.type === "check"; e = this.entries[this.next]) {
1896
+ this.expect(e.delivered === this.delivered, () => `the recording was idle after ${e.delivered} results, the replay after ${this.delivered}`);
1897
+ this.expect(e.issued === this.issued, () => `${this.issued} host calls made, the recording made ${e.issued} (after ${e.delivered} results)`);
1898
+ this.expect(e.hash === hash, () => `the state after ${e.delivered} results differs`);
1899
+ this.next++;
1900
+ }
1901
+ }
1902
+ /** At the end: records or checks the `end` entry. */
1903
+ finish(end, hash) {
1904
+ const full = { ...end, delivered: this.delivered, issued: this.issued, hash };
1905
+ if (this.mode === "record") {
1906
+ this.push(full);
1907
+ return;
1908
+ }
1909
+ while (this.entries[this.next]?.type === "check")
1910
+ this.next++;
1911
+ const e = this.take("end");
1912
+ this.expect(e.ok === full.ok && sameValue(e.value, full.value), () => `the run ended ${full.ok ? "with" : "throwing"} ${stable(full.value)}, the recording ${e.ok ? "with" : "throwing"} ${stable(e.value)}`);
1913
+ this.expect(e.issued === full.issued && e.delivered === full.delivered, () => "a different number of host calls or results");
1914
+ this.expect(e.hash === full.hash, () => "the final state differs");
1915
+ }
1916
+ }
1917
+ /** A host value as a journal records it: errors as `{ name, message }`. */
1918
+ function errorJson(ok, v) {
1919
+ if (ok)
1920
+ return v;
1921
+ return v instanceof Error ? { name: v.name, message: v.message } : { name: "Error", message: String(v) };
1922
+ }
1923
+ /** The host error a journal entry stands for, of the same kind. */
1924
+ function errorFromJson(v) {
1925
+ const { name, message } = (v ?? {});
1926
+ const E = name === "TypeError" ? TypeError : name === "RangeError" ? RangeError : Error;
1927
+ return new E(message ?? "");
1928
+ }
1929
+ /** A stable text form of a host value, for comparing and messages. */
1930
+ function stable(v) {
1931
+ return JSON.stringify(v, (_k, x) => typeof x === "bigint" ? `${x}n` : x === undefined ? "<undefined>" : x instanceof Uint8Array ? Array.from(x) : x) ?? "<undefined>";
1932
+ }
1933
+ function sameValue(a, b) {
1934
+ return stable(a) === stable(b);
1935
+ }
1936
+ /** A 64-bit seed as the two u32 words `vm_set_random_seed` takes. */
1937
+ function seedWords(seed) {
1938
+ const s = BigInt.asUintN(64, BigInt(seed));
1939
+ return [Number(s & 0xffffffffn), Number(s >> 32n)];
1940
+ }
1941
+ /** A seed from the host's entropy (never 0, which would mean the VM's fixed default). */
1942
+ function entropy() {
1943
+ const words = new Uint32Array(2);
1944
+ globalThis.crypto.getRandomValues(words);
1945
+ return [words[0], words[1] | 1];
1946
+ }
1947
+ /** Console arguments: values without a host form are shown by their class. */
1948
+ function consoleArg(a) {
1949
+ return a instanceof Opaque ? `[${a.className ?? a.type}]` : a;
1950
+ }
1951
+ /** The first `n` u32 (LE) words of the out buffer, which need not be aligned. */
1952
+ function outWords(x, n) {
1953
+ const dv = new DataView(x.memory.buffer, x.djs_out_ptr(), 4 * n);
1954
+ return Array.from({ length: n }, (_, i) => dv.getUint32(4 * i, true));
1955
+ }
1956
+ function outText(x) {
1957
+ return textDecoder.decode(new Uint8Array(x.memory.buffer, x.djs_out_ptr(), x.djs_out_len()));
1958
+ }