@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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +88 -2
- package/dist/base.d.ts +33 -0
- package/dist/base.js +71 -0
- package/dist/dump-runtime.d.ts +16 -0
- package/dist/dump-runtime.js +107 -0
- package/dist/dump.d.ts +10 -0
- package/dist/dump.js +21 -0
- package/dist/env.d.ts +5 -0
- package/dist/env.js +17 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +21 -0
- package/dist/layout.generated.d.ts +921 -0
- package/dist/layout.generated.js +442 -0
- package/dist/reader.d.ts +47 -0
- package/dist/reader.js +599 -0
- package/dist/sandbox.d.ts +756 -0
- package/dist/sandbox.js +1958 -0
- package/dist/slim.d.ts +10 -0
- package/dist/slim.js +21 -0
- package/dist/trap.d.ts +16 -0
- package/dist/trap.js +23 -0
- package/dist/wire.d.ts +46 -0
- package/dist/wire.js +373 -0
- package/package.json +53 -4
- package/wasm/djs-dump.wasm +0 -0
- package/wasm/djs-slim.wasm +0 -0
- package/wasm/djs.wasm +0 -0
package/dist/sandbox.js
ADDED
|
@@ -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
|
+
}
|