Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
/**
|
|
* Drives one QuickJS VM through one script, independent of the transport that connects it to the
|
|
* host. The worker thread, the in-thread launcher, and the remote server all run the VM through
|
|
* this module, so the bridge between prelude and host exists once.
|
|
*
|
|
* Contract with the caller:
|
|
* - `post` receives every message for the host, synchronously from inside VM callbacks. The
|
|
* caller must not re-enter the VM from `post` (no `deliver`, no `dispose`); a worker posts to
|
|
* another thread, and the in-thread launcher defers delivery to a microtask.
|
|
* - `deliver` hands the VM a tool result and runs the script until it waits again. It must only be
|
|
* called while the VM is idle, which holds for any caller that never re-enters from `post`.
|
|
* - `shouldInterrupt` is polled by QuickJS while the script runs, and here between batches of
|
|
* promise jobs. Returning true throws an uncatchable error in the script; the VM then reports
|
|
* `crash` or a failed `done`. The job batches matter for a script such as `while (true) await
|
|
* null`: every iteration is a separate job that executes only a few opcodes, so QuickJS's own
|
|
* polling, which counts opcodes, would let it run for a long time between polls.
|
|
* - The VM never decides on its own when it is finished: after `done` or `crash` it stays alive
|
|
* until the caller disposes it (or terminates the worker that hosts it).
|
|
*
|
|
* Failures after the VM exists are reported as a `crash` message; `startVm` only rejects when the
|
|
* VM cannot be created at all.
|
|
*
|
|
* Deterministic mode (`data.deterministic`, used by `CodemodeSandboxBase.start()`): a script must make the same
|
|
* calls with the same arguments every time it runs with the same results, so a host can replay it after a restart.
|
|
* The VM is otherwise deterministic, because it only runs when the host delivers something and has no timers.
|
|
* What remains is fixed here:
|
|
* - Time: every WASI clock reads virtual time, `startedAt` and then the `now` of the result being processed, never going backwards.
|
|
* While the context is created, the realtime clock reads a value derived from the seed instead, because QuickJS
|
|
* seeds `Math.random()` from the first clock read, and which read comes first is the engine's business.
|
|
* - Randomness: `random_get` draws from a PRNG over the seed.
|
|
* - Timezone: UTC, not the host's.
|
|
* - Garbage collection: `WeakRef` and `FinalizationRegistry` are not available; collection timing is not.
|
|
* - Delivery order: results carry a `seq` and are applied strictly in that order, buffering early ones, because a
|
|
* transport (RPC replies) may deliver them in another order than the host sent them.
|
|
* The VM reports `started` with its fingerprint before the script runs, and `idle` with the number of calls issued
|
|
* after each drain, so the host can check a replay step by step.
|
|
*/
|
|
import { Intrinsics, JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
|
|
import type { CodemodeWasmModule } from "../wasm.ts";
|
|
import { PRELUDE_SOURCE } from "./prelude-source.ts";
|
|
import type { HostToWorkerMessage, VmStartData, WorkerToHostMessage } from "./protocol.ts";
|
|
|
|
export interface VmSession {
|
|
deliver(message: HostToWorkerMessage): void;
|
|
/** Releases the VM's wasm instance. The session must not be used afterwards. */
|
|
dispose(): void;
|
|
}
|
|
|
|
export interface StartVmOptions {
|
|
data: VmStartData;
|
|
wasm: CodemodeWasmModule;
|
|
/**
|
|
* QuickJS's guard on its own stack, which turns deep recursion into a catchable `RangeError`.
|
|
* The wasm frames also use the host thread's native stack, so the guard must trip before that
|
|
* stack runs out: a host overflow fails the whole execution instead. Default: `MAX_STACK_SIZE`
|
|
* (512 KiB), which fits the 4 MB stack of a Node worker thread but not a 1 MB main thread.
|
|
*/
|
|
maxStackSize?: number;
|
|
/** Interrupt polls this VM may use, if the launcher counts them; part of the fingerprint. */
|
|
interruptBudget?: number;
|
|
post(message: WorkerToHostMessage): void;
|
|
shouldInterrupt(): boolean;
|
|
}
|
|
|
|
/**
|
|
* Bumped whenever this module or the prelude changes what a deterministic script observes, beyond what the
|
|
* fingerprint's other parts (engine versions, prelude hash, limits) already capture.
|
|
*/
|
|
const DETERMINISTIC_VERSION = 1;
|
|
|
|
/** FNV-1a over UTF-16 code units, as 8 hex digits: identifies the prelude in the fingerprint. */
|
|
function fnv1a(text: string): string {
|
|
let hash = 0x811c9dc5;
|
|
for (let index = 0; index < text.length; index++) {
|
|
hash ^= text.charCodeAt(index);
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
}
|
|
|
|
const PRELUDE_HASH = fnv1a(PRELUDE_SOURCE);
|
|
|
|
/** mulberry32: a small, fast PRNG; one 32-bit draw per call. */
|
|
function prng(seed: number): () => number {
|
|
let state = seed >>> 0;
|
|
return () => {
|
|
state = (state + 0x6d2b79f5) >>> 0;
|
|
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
|
return (value ^ (value >>> 14)) >>> 0;
|
|
};
|
|
}
|
|
|
|
/** The deterministic VM's clock: seed-derived while the context is created, then virtual time. */
|
|
interface VirtualClock {
|
|
creating: boolean;
|
|
startedAt: number;
|
|
now: number;
|
|
seed: number;
|
|
}
|
|
|
|
/** WASI clock and random functions for deterministic mode, on top of {@link discardOutput}. */
|
|
function deterministicWasi(clock: VirtualClock) {
|
|
const random = prng(clock.seed ^ 0x9e3779b9);
|
|
return (memory: { readonly buffer: ArrayBufferLike }) => ({
|
|
...discardOutput(memory),
|
|
clock_time_get(clockId: number, _precision: bigint, resultPtr: number): number {
|
|
let nanoseconds: bigint;
|
|
// QuickJS seeds Math.random() from this reading in microseconds and turns 0 into 1, so seed s reads s + 1.
|
|
if (clock.creating) nanoseconds = clockId === 0 ? BigInt(clock.seed + 1) * 1000n : 0n;
|
|
else if (clockId === 0) nanoseconds = BigInt(Math.round(clock.now)) * 1_000_000n;
|
|
else nanoseconds = BigInt(Math.round(clock.now - clock.startedAt)) * 1_000_000n;
|
|
new DataView(memory.buffer).setBigUint64(resultPtr, nanoseconds, true);
|
|
return 0;
|
|
},
|
|
random_get(bufPtr: number, bufLen: number): number {
|
|
const bytes = new Uint8Array(memory.buffer, bufPtr, bufLen);
|
|
for (let index = 0; index < bufLen; index += 4) {
|
|
const value = random();
|
|
for (let byte = 0; byte < 4 && index + byte < bufLen; byte++) bytes[index + byte] = value >>> (byte * 8);
|
|
}
|
|
return 0;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* QuickJS writes engine diagnostics to fd 1 and 2, which the default shim
|
|
* forwards to the host's stdout and stderr. That output belongs to the host
|
|
* application (for example a TUI), so it is discarded. Reporting every byte as
|
|
* written keeps libc from retrying.
|
|
*/
|
|
function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
|
|
return {
|
|
fd_write(_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number {
|
|
const view = new DataView(memory.buffer);
|
|
let written = 0;
|
|
for (let i = 0; i < iovsLen; i++) {
|
|
written += view.getUint32(iovsPtr + i * 8 + 4, true);
|
|
}
|
|
view.setUint32(nwrittenPtr, written, true);
|
|
return 0;
|
|
},
|
|
};
|
|
}
|
|
|
|
function describeException(error: JSException): string {
|
|
const head = error.message ? `${error.name}: ${error.message}` : error.name;
|
|
const stack = error.stack?.trimEnd();
|
|
return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head });
|
|
}
|
|
|
|
/** Promise jobs run between two `shouldInterrupt` polls while draining the job queue. */
|
|
const JOBS_PER_POLL = 50;
|
|
|
|
function crashMessage(error: unknown): WorkerToHostMessage {
|
|
return { type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
|
|
}
|
|
|
|
export async function startVm(options: StartVmOptions): Promise<VmSession> {
|
|
const { data, post } = options;
|
|
const maxStackSize = options.maxStackSize ?? MAX_STACK_SIZE;
|
|
const deterministic = data.deterministic;
|
|
const clock: VirtualClock | undefined = deterministic && {
|
|
creating: true,
|
|
startedAt: deterministic.startedAt,
|
|
now: deterministic.startedAt,
|
|
seed: deterministic.seed >>> 0,
|
|
};
|
|
const vm = await QuickJS.create({
|
|
wasm: options.wasm,
|
|
memoryLimit: data.memoryLimitBytes,
|
|
// Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a
|
|
// catchable RangeError.
|
|
maxStackSize,
|
|
interruptHandler: options.shouldInterrupt,
|
|
wasi: clock ? deterministicWasi(clock) : discardOutput,
|
|
...(clock ? { timezoneOffset: 0, intrinsics: Intrinsics.ALL & ~Intrinsics.WEAK_REF } : {}),
|
|
});
|
|
if (clock) clock.creating = false;
|
|
let disposed = false;
|
|
/** Calls the script has issued, tools and globals; reported with every `idle`. */
|
|
let issued = 0;
|
|
/** Deterministic mode: the next result to apply, and results that arrived before it. */
|
|
let nextSeq = 1;
|
|
/** The result being processed, 0 during the script's first run; reported with `done`. */
|
|
let current = 0;
|
|
const early = new Map<number, HostToWorkerMessage>();
|
|
const apply = (message: HostToWorkerMessage): void => {
|
|
vm.withScope(() => {
|
|
vm.callFunction(
|
|
settle,
|
|
api,
|
|
vm.newNumber(message.id),
|
|
message.ok ? vm.true : vm.false,
|
|
message.payload === undefined ? vm.undefined : vm.newString(message.payload),
|
|
message.properties === undefined ? vm.undefined : vm.newString(message.properties),
|
|
);
|
|
});
|
|
drain();
|
|
};
|
|
const session: VmSession = {
|
|
deliver(message) {
|
|
if (disposed) return;
|
|
try {
|
|
if (!clock) {
|
|
apply(message);
|
|
return;
|
|
}
|
|
const { seq } = message;
|
|
if (seq === undefined || !Number.isInteger(seq) || seq < nextSeq || early.has(seq)) {
|
|
throw new Error(`Result ${message.id} has sequence number ${seq}, expected ${nextSeq} or later, once`);
|
|
}
|
|
early.set(seq, message);
|
|
for (let next = early.get(nextSeq); next !== undefined; next = early.get(nextSeq)) {
|
|
early.delete(nextSeq);
|
|
// Never backwards: a resumed host's clock may be behind the one that started the run.
|
|
if (next.now !== undefined && Number.isFinite(next.now)) clock.now = Math.max(clock.now, next.now);
|
|
current = nextSeq;
|
|
apply(next);
|
|
post({ type: "idle", seq: nextSeq, issued });
|
|
nextSeq++;
|
|
}
|
|
} catch (error) {
|
|
post(crashMessage(error));
|
|
}
|
|
},
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
vm.dispose();
|
|
},
|
|
};
|
|
|
|
let api: JSValueHandle;
|
|
let settle: JSValueHandle;
|
|
let stalled: JSValueHandle;
|
|
// `executePendingJobs()` runs the queue to empty without a chance to interrupt, so jobs run one at a
|
|
// time through the (internal, but public) exports; quickjs-wasi is pinned to an exact version.
|
|
const exports = vm._getExports();
|
|
/** Run queued jobs, then fail a script that waits on nothing that can ever resume it. */
|
|
const drain = () => {
|
|
let jobs = 0;
|
|
while (exports.qjs_is_job_pending()) {
|
|
// A job only fails for uncatchable errors, such as an interrupt.
|
|
if (exports.qjs_execute_pending_job() < 0) throw new Error("Promise job failed");
|
|
if (++jobs % JOBS_PER_POLL === 0 && options.shouldInterrupt()) throw new Error("Execution interrupted");
|
|
}
|
|
vm.callFunction(stalled, api).dispose();
|
|
};
|
|
|
|
try {
|
|
// Called from the prelude with primitives only.
|
|
const bridge = vm.newFunction("bridge", (kind, a, b, c) => {
|
|
switch (kind.toString()) {
|
|
case "call":
|
|
case "global":
|
|
issued++;
|
|
post({
|
|
type: "call",
|
|
id: a.toNumber(),
|
|
target: kind.toString() === "call" ? "tool" : "global",
|
|
name: b.toString(),
|
|
args: c === undefined || c.isUndefined ? undefined : c.toString(),
|
|
});
|
|
break;
|
|
case "output":
|
|
post({
|
|
type: "output",
|
|
item:
|
|
a.toString() === "image"
|
|
? { type: "image", data: b.toString(), mimeType: c.toString() }
|
|
: a.toString() === "console"
|
|
? { type: "text", text: b.toString(), console: true }
|
|
: { type: "text", text: b.toString() },
|
|
});
|
|
break;
|
|
case "done": {
|
|
// The host stops the VM on `done`, so the `idle` of this drain never arrives: report its numbers here.
|
|
const during = clock ? { during: { seq: current, issued } } : {};
|
|
if (a.toBoolean()) {
|
|
post({
|
|
type: "done",
|
|
ok: true,
|
|
value: b === undefined || b.isUndefined ? undefined : b.toString(),
|
|
writes: c.toString(),
|
|
...during,
|
|
});
|
|
} else {
|
|
post({ type: "done", ok: false, error: b.toString(), ...during });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return vm.undefined;
|
|
});
|
|
|
|
// These handles live as long as the VM, which the caller disposes as a whole.
|
|
api = vm.withScope((scope) =>
|
|
scope.escape(
|
|
vm.callFunction(
|
|
vm.evalCode(PRELUDE_SOURCE, "codemode-prelude.js"),
|
|
vm.undefined,
|
|
bridge,
|
|
vm.newString(JSON.stringify(data.tools)),
|
|
vm.newString(JSON.stringify(data.globals)),
|
|
vm.newString(JSON.stringify(data.store)),
|
|
),
|
|
),
|
|
);
|
|
settle = api.getProp("settle");
|
|
const run = api.getProp("run");
|
|
stalled = api.getProp("stalled");
|
|
if (clock) {
|
|
const versions = vm.versions;
|
|
const budget = options.interruptBudget ?? "none";
|
|
post({
|
|
type: "started",
|
|
fingerprint: `quickjs-wasi ${versions["quickjs-wasi"]}, quickjs ${versions.quickjs}, prelude ${PRELUDE_HASH}, deterministic ${DETERMINISTIC_VERSION}, stack ${maxStackSize}, heap ${data.memoryLimitBytes ?? "none"}, budget ${budget}`,
|
|
});
|
|
}
|
|
|
|
// The prefix shares the first line with the script so reported line numbers
|
|
// match the script as written.
|
|
let fn: JSValueHandle;
|
|
try {
|
|
fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js");
|
|
} catch (error) {
|
|
if (!(error instanceof JSException)) throw error;
|
|
post({ type: "done", ok: false, error: describeException(error) });
|
|
return session;
|
|
}
|
|
vm.callFunction(run, api, fn).dispose();
|
|
fn.dispose();
|
|
drain();
|
|
if (clock) post({ type: "idle", seq: 0, issued });
|
|
} catch (error) {
|
|
post(crashMessage(error));
|
|
}
|
|
return session;
|
|
}
|