chore(vendor): import pi 1.3.0
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
This commit is contained in:
573
third_party/pi/packages/codemode/src/runtime/execution.ts
vendored
Normal file
573
third_party/pi/packages/codemode/src/runtime/execution.ts
vendored
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Host side of an execution, shared by every place a VM can run.
|
||||
*
|
||||
* The host owns everything the script must not influence: the tool table, call records, output,
|
||||
* the deadline, the caller's abort signal, and the decoding of what the VM reports. The VM itself
|
||||
* runs somewhere a {@link VmLauncher} decides: a worker thread (`CodemodeSandbox`), the calling
|
||||
* thread (`InlineCodemodeSandbox`), or another isolate reached over RPC (`RemoteCodemodeSandbox`).
|
||||
* The two sides only exchange the messages in `protocol.ts`, so the launchers differ in transport
|
||||
* and termination, not in behavior.
|
||||
*
|
||||
* Lifecycle of one execution:
|
||||
* 1. The constructor arms the deadline and abort listener, then asks the launcher for a channel.
|
||||
* 2. Messages from the VM arrive through {@link VmEvents.message}; tool calls run here and their
|
||||
* results go back through {@link VmChannel.post}. Replies produced before the launcher has
|
||||
* returned its channel are queued and flushed when it arrives.
|
||||
* 3. The first terminal event wins: `done`, `crash`, a launcher failure, the deadline, or an abort.
|
||||
* `finish()` aborts pending tool calls, asks the channel to stop, and resolves the result once
|
||||
* that request completes. A worker or remote VM may exit later. Later events are ignored.
|
||||
*
|
||||
* The launcher, not this class, is responsible for actually stopping a running VM: a worker is
|
||||
* terminated, an in-thread VM is interrupted and disposed, and a remote VM is told to stop at its
|
||||
* next exchange.
|
||||
*/
|
||||
import { toCodemodeIdentifier } from "../identifier.ts";
|
||||
import type {
|
||||
CodemodeCall,
|
||||
CodemodeCallStatus,
|
||||
CodemodeDeclaredGlobal,
|
||||
CodemodeDeclaredTool,
|
||||
CodemodeDuring,
|
||||
CodemodeError,
|
||||
CodemodeExecuteOptions,
|
||||
CodemodeOutcome,
|
||||
CodemodeOutputItem,
|
||||
CodemodeResult,
|
||||
CodemodeRun,
|
||||
CodemodeRunOptions,
|
||||
CodemodeSandboxBaseOptions,
|
||||
CodemodeStoreWrites,
|
||||
CodemodeTool,
|
||||
} from "../types.ts";
|
||||
import {
|
||||
type HostToWorkerMessage,
|
||||
isWorkerToHostMessage,
|
||||
type VmStartData,
|
||||
type WorkerToHostMessage,
|
||||
} from "./protocol.ts";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
const RESERVED_GLOBALS: ReadonlySet<string> = new Set([
|
||||
"tools",
|
||||
"ALL_TOOLS",
|
||||
"console",
|
||||
"text",
|
||||
"image",
|
||||
"exit",
|
||||
"globalThis",
|
||||
"store",
|
||||
"load",
|
||||
]);
|
||||
|
||||
/** Callbacks a launcher uses to report what its VM does. */
|
||||
export interface VmEvents {
|
||||
/** A message from the VM. Validated here, so launchers can pass through anything they receive. */
|
||||
message(message: unknown): void;
|
||||
/** The VM failed or stopped outside the bridge, for example a worker exit or a remote error. */
|
||||
failure(error: CodemodeError): void;
|
||||
}
|
||||
|
||||
/** The host's handle on a running VM. */
|
||||
export interface VmChannel {
|
||||
post(message: HostToWorkerMessage): void;
|
||||
/** Requests VM shutdown. Called once; may resolve before a worker or remote VM actually exits. */
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a VM for one execution. Rejections become `sandbox` errors with the rejection's message.
|
||||
* The VM may start reporting messages before the returned promise settles.
|
||||
*/
|
||||
export type VmLauncher = (data: VmStartData, events: VmEvents) => VmChannel | Promise<VmChannel>;
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function serializeStore(store: Readonly<Record<string, unknown>> | undefined): Record<string, string> {
|
||||
const serialized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(store ?? {})) {
|
||||
const json = JSON.stringify(value);
|
||||
if (json !== undefined) serialized[key] = json;
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prelude that serializes values for the host runs in the same VM as the script, so a script
|
||||
* that patches built-ins (for example `Array.prototype.toJSON`) can make it send malformed data.
|
||||
* Payloads that do not decode throw this error, and the execution fails as a sandbox error.
|
||||
*/
|
||||
class BridgeError extends Error {}
|
||||
|
||||
function parseBridgeJson(json: string, what: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
throw new BridgeError(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoreWrites(json: string): CodemodeStoreWrites {
|
||||
const entries = parseBridgeJson(json, "store writes");
|
||||
if (!Array.isArray(entries)) throw new BridgeError("store writes are not an array");
|
||||
const writes: CodemodeStoreWrites = { set: {}, delete: [] };
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!Array.isArray(entry) ||
|
||||
typeof entry[0] !== "string" ||
|
||||
(entry.length === 2 ? typeof entry[1] !== "string" : entry.length !== 1)
|
||||
) {
|
||||
throw new BridgeError("store writes contain a malformed entry");
|
||||
}
|
||||
const [key, value] = entry as [string, string?];
|
||||
if (value === undefined) writes.delete.push(key);
|
||||
else writes.set[key] = parseBridgeJson(value, `store value for ${JSON.stringify(key)}`);
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
function parseScriptError(json: string): CodemodeError {
|
||||
const parsed = parseBridgeJson(json, "script error");
|
||||
if (typeof parsed !== "object" || parsed === null) throw new BridgeError("script error is not an object");
|
||||
const { name, message, stack } = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof message !== "string" ||
|
||||
(name !== undefined && typeof name !== "string") ||
|
||||
(stack !== undefined && typeof stack !== "string")
|
||||
) {
|
||||
throw new BridgeError("script error is malformed");
|
||||
}
|
||||
return { kind: "script", name, message, stack };
|
||||
}
|
||||
|
||||
interface PendingCall {
|
||||
record: CodemodeCall | undefined;
|
||||
startedAt: number;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
/** Checks global names: identifiers or `namespace.member`, not shadowing a built-in global or each other. */
|
||||
function checkGlobals(globals: readonly CodemodeDeclaredGlobal[]): void {
|
||||
const names = new Set<string>();
|
||||
const namespaces = new Set<string>();
|
||||
for (const global of globals) {
|
||||
const parts = global.name.split(".");
|
||||
if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part)) || RESERVED_GLOBALS.has(parts[0])) {
|
||||
throw new Error(`Invalid global name "${global.name}"`);
|
||||
}
|
||||
if (names.has(global.name)) throw new Error(`Global "${global.name}" is already registered`);
|
||||
if (parts.length === 2) namespaces.add(parts[0]);
|
||||
names.add(global.name);
|
||||
}
|
||||
for (const name of namespaces) {
|
||||
if (names.has(name)) throw new Error(`Global "${name}" conflicts with the namespace "${name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Callbacks of a host-controlled run: its calls go to the caller, which answers them with `deliver()`. */
|
||||
type Control = Pick<CodemodeRunOptions, "onStarted" | "onCall" | "onIdle">;
|
||||
|
||||
interface ExecutionOptions {
|
||||
code: string;
|
||||
tools: readonly CodemodeDeclaredTool[];
|
||||
globals: readonly CodemodeDeclaredGlobal[];
|
||||
/** Run the script's calls; unused when `control` is set. */
|
||||
executors: { tools: ReadonlyMap<string, CodemodeTool>; globals: ReadonlyMap<string, CodemodeTool> };
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal | undefined;
|
||||
memoryLimitBytes: number | undefined;
|
||||
store: Record<string, string>;
|
||||
deterministic?: { startedAt: number; seed: number };
|
||||
control?: Control;
|
||||
launch: VmLauncher;
|
||||
}
|
||||
|
||||
/** One script run in its own VM. */
|
||||
class Execution {
|
||||
readonly promise: Promise<CodemodeResult>;
|
||||
private resolveResult!: (result: CodemodeResult) => void;
|
||||
private channel: VmChannel | undefined;
|
||||
/** Replies produced before the launcher returned the channel. */
|
||||
private unsent: HostToWorkerMessage[] = [];
|
||||
private readonly executors: ExecutionOptions["executors"];
|
||||
private readonly control: Control | undefined;
|
||||
/** Result numbers `deliver()` used. */
|
||||
private readonly delivered = new Set<number>();
|
||||
private readonly signal: AbortSignal | undefined;
|
||||
private readonly timer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly output: CodemodeOutputItem[] = [];
|
||||
private readonly calls: CodemodeCall[] = [];
|
||||
private readonly pending = new Map<number, PendingCall>();
|
||||
/** Whether the execution has its outcome. */
|
||||
finished = false;
|
||||
|
||||
constructor(options: ExecutionOptions) {
|
||||
this.promise = new Promise<CodemodeResult>((resolve) => {
|
||||
this.resolveResult = resolve;
|
||||
});
|
||||
this.executors = options.executors;
|
||||
this.control = options.control;
|
||||
this.signal = options.signal;
|
||||
|
||||
if (Number.isFinite(options.timeoutMs)) {
|
||||
this.timer = setTimeout(() => {
|
||||
this.finish({ kind: "timeout", message: `Execution timed out after ${options.timeoutMs} ms` });
|
||||
}, options.timeoutMs);
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
this.onAbort();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", this.onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
if (this.finished) return;
|
||||
|
||||
const data: VmStartData = {
|
||||
code: options.code,
|
||||
tools: options.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
jsName: tool.jsName ?? toCodemodeIdentifier(tool.name),
|
||||
description: tool.description ?? "",
|
||||
})),
|
||||
globals: options.globals.map((global) => ({ name: global.name, spread: global.spread === true })),
|
||||
memoryLimitBytes: options.memoryLimitBytes,
|
||||
store: options.store,
|
||||
...(options.deterministic ? { deterministic: options.deterministic } : {}),
|
||||
};
|
||||
const events: VmEvents = {
|
||||
message: (message) => this.receive(message),
|
||||
failure: (error) => this.finish(error),
|
||||
};
|
||||
new Promise<VmChannel>((resolve) => resolve(options.launch(data, events))).then(
|
||||
(channel) => {
|
||||
if (this.finished) {
|
||||
void channel.stop().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
this.channel = channel;
|
||||
for (const message of this.unsent.splice(0)) channel.post(message);
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.finish({ kind: "sandbox", message: errorMessage(error) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** `CodemodeRun.deliver()`. */
|
||||
deliver(seq: number, now: number, id: number, outcome: CodemodeOutcome): void {
|
||||
if (this.finished) return;
|
||||
if (!Number.isFinite(now)) throw new Error(`The clock value of result ${seq} is not a finite number`);
|
||||
if (!Number.isInteger(seq) || seq < 1 || this.delivered.has(seq)) {
|
||||
throw new Error(`Result number ${seq} is not a positive integer used once`);
|
||||
}
|
||||
const pending = this.pending.get(id);
|
||||
if (pending === undefined) throw new Error(`Call ${id} is not waiting for a result`);
|
||||
if (!outcome.ok && outcome.properties !== undefined) {
|
||||
const { properties } = outcome;
|
||||
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
|
||||
throw new Error(`The properties of result ${seq} are not an object`);
|
||||
}
|
||||
}
|
||||
// Serialized before anything is recorded, so a value JSON cannot hold leaves the call waiting for another answer.
|
||||
const payload = outcome.ok
|
||||
? outcome.value === undefined
|
||||
? undefined
|
||||
: JSON.stringify(outcome.value)
|
||||
: outcome.message;
|
||||
const properties =
|
||||
!outcome.ok && outcome.properties !== undefined ? JSON.stringify(outcome.properties) : undefined;
|
||||
this.delivered.add(seq);
|
||||
this.pending.delete(id);
|
||||
if (pending.record) {
|
||||
pending.record.status = outcome.ok ? "ok" : "error";
|
||||
pending.record.durationMs = performance.now() - pending.startedAt;
|
||||
}
|
||||
this.post({
|
||||
type: "result",
|
||||
id,
|
||||
ok: outcome.ok,
|
||||
payload,
|
||||
seq,
|
||||
now,
|
||||
...(properties === undefined ? {} : { properties }),
|
||||
});
|
||||
}
|
||||
|
||||
abort(message: string): Promise<CodemodeResult> {
|
||||
this.finish({ kind: "aborted", message });
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
private readonly onAbort = (): void => {
|
||||
const reason: unknown = this.signal?.reason;
|
||||
this.finish({ kind: "aborted", message: reason instanceof Error ? reason.message : "Execution aborted" });
|
||||
};
|
||||
|
||||
private post(message: HostToWorkerMessage): void {
|
||||
if (this.channel) this.channel.post(message);
|
||||
else this.unsent.push(message);
|
||||
}
|
||||
|
||||
/** An exception here would escape into the launcher's transport and leave the execution unsettled. */
|
||||
private receive(message: unknown): void {
|
||||
try {
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
this.finish({
|
||||
kind: "sandbox",
|
||||
message:
|
||||
error instanceof BridgeError
|
||||
? `Sandbox bridge broken: ${error.message}. The script may have modified built-ins such as a prototype's toJSON.`
|
||||
: `Sandbox host failed: ${errorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: unknown): void {
|
||||
if (this.finished) return;
|
||||
if (!isWorkerToHostMessage(message)) throw new BridgeError("unknown message from the worker");
|
||||
switch (message.type) {
|
||||
case "output":
|
||||
this.output.push(message.item);
|
||||
break;
|
||||
case "call":
|
||||
if (this.pending.has(message.id)) throw new BridgeError(`duplicate call id ${message.id}`);
|
||||
if (this.control) this.forwardCall(message, this.control);
|
||||
else void this.handleCall(message);
|
||||
break;
|
||||
case "started":
|
||||
this.control?.onStarted?.(message.fingerprint);
|
||||
break;
|
||||
case "idle":
|
||||
this.control?.onIdle?.(message.seq, message.issued);
|
||||
break;
|
||||
case "done":
|
||||
this.handleDone(message);
|
||||
break;
|
||||
case "crash":
|
||||
this.finish({ kind: "sandbox", message: message.message });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDone(message: Extract<WorkerToHostMessage, { type: "done" }>): void {
|
||||
// Decode everything before finish(), which must not throw once it starts.
|
||||
if (!message.ok) {
|
||||
this.finish(parseScriptError(message.error), undefined, undefined, message.during);
|
||||
return;
|
||||
}
|
||||
const value = message.value === undefined ? undefined : parseBridgeJson(message.value, "return value");
|
||||
this.finish(undefined, value, parseStoreWrites(message.writes), message.during);
|
||||
}
|
||||
|
||||
/** A host-controlled run: record the call and hand it to the caller, which answers with `deliver()`. */
|
||||
private forwardCall(message: Extract<WorkerToHostMessage, { type: "call" }>, control: Control): void {
|
||||
const { id, name, target } = message;
|
||||
const args = message.args === undefined ? undefined : parseBridgeJson(message.args, "call arguments");
|
||||
const record: CodemodeCall | undefined =
|
||||
target === "tool" ? { name, status: "cancelled", durationMs: 0 } : undefined;
|
||||
if (record) this.calls.push(record);
|
||||
this.pending.set(id, { record, startedAt: performance.now(), controller: new AbortController() });
|
||||
control.onCall({ id, target, name, args });
|
||||
}
|
||||
|
||||
private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> {
|
||||
const { id, name } = message;
|
||||
const isTool = message.target === "tool";
|
||||
const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined;
|
||||
if (record) this.calls.push(record);
|
||||
const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() };
|
||||
this.pending.set(id, pending);
|
||||
|
||||
let status: CodemodeCallStatus;
|
||||
let reply: HostToWorkerMessage;
|
||||
try {
|
||||
const tool = (isTool ? this.executors.tools : this.executors.globals).get(name);
|
||||
if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`);
|
||||
const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args);
|
||||
const value = await tool.execute(args, { signal: pending.controller.signal });
|
||||
reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) };
|
||||
status = "ok";
|
||||
} catch (error) {
|
||||
reply = { type: "result", id, ok: false, payload: errorMessage(error) };
|
||||
status = "error";
|
||||
}
|
||||
|
||||
// Already cancelled by finish(): the record keeps "cancelled" and the
|
||||
// VM is gone or going.
|
||||
if (!this.pending.delete(id)) return;
|
||||
if (record) {
|
||||
record.status = status;
|
||||
record.durationMs = performance.now() - pending.startedAt;
|
||||
}
|
||||
this.post(reply);
|
||||
}
|
||||
|
||||
private finish(
|
||||
error: CodemodeError | undefined,
|
||||
value?: unknown,
|
||||
writes?: CodemodeStoreWrites,
|
||||
during?: CodemodeDuring,
|
||||
): void {
|
||||
if (this.finished) return;
|
||||
this.finished = true;
|
||||
clearTimeout(this.timer);
|
||||
this.signal?.removeEventListener("abort", this.onAbort);
|
||||
|
||||
const now = performance.now();
|
||||
for (const pending of this.pending.values()) {
|
||||
if (pending.record) pending.record.durationMs = now - pending.startedAt;
|
||||
pending.controller.abort();
|
||||
}
|
||||
this.pending.clear();
|
||||
this.unsent = [];
|
||||
|
||||
const ended = during === undefined ? {} : { during: { seq: during.seq, issued: during.issued } };
|
||||
const result: CodemodeResult = error
|
||||
? { ok: false, error, output: this.output, calls: this.calls, ...ended }
|
||||
: {
|
||||
ok: true,
|
||||
value,
|
||||
output: this.output,
|
||||
calls: this.calls,
|
||||
storeWrites: writes ?? { set: {}, delete: [] },
|
||||
...ended,
|
||||
};
|
||||
if (!this.channel) {
|
||||
this.resolveResult(result);
|
||||
return;
|
||||
}
|
||||
this.channel
|
||||
.stop()
|
||||
.catch(() => undefined)
|
||||
.then(() => this.resolveResult(result));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool table, defaults, and in-flight executions; the behavior every sandbox shares. Subclasses
|
||||
* only choose where the VM runs by passing a {@link VmLauncher}.
|
||||
*
|
||||
* The script sees `tools.<name>(args)` for every registered tool, `ALL_TOOLS`, the output helpers
|
||||
* `text`, `image`, `exit`, and `console.*`, `store`/`load`, and the configured globals; nothing
|
||||
* else (no timers, `fetch`, `process`, `require`, modules). Each `execute()` gets its own VM; the
|
||||
* sandbox only holds the tool table and defaults. `close()` aborts in-flight executions.
|
||||
*/
|
||||
export class CodemodeSandboxBase {
|
||||
private readonly toolsByName = new Map<string, CodemodeTool>();
|
||||
private readonly globalsByName = new Map<string, CodemodeTool>();
|
||||
private readonly timeoutMs: number;
|
||||
private readonly memoryLimitBytes: number | undefined;
|
||||
private readonly launch: VmLauncher;
|
||||
private readonly running = new Set<Execution>();
|
||||
private closed = false;
|
||||
|
||||
constructor(options: CodemodeSandboxBaseOptions, launch: VmLauncher) {
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.memoryLimitBytes = options.memoryLimitBytes;
|
||||
this.launch = launch;
|
||||
for (const tool of options.tools ?? []) this.registerTool(tool);
|
||||
checkGlobals(options.globals ?? []);
|
||||
for (const global of options.globals ?? []) this.globalsByName.set(global.name, global);
|
||||
}
|
||||
|
||||
/** Throws if a tool with the same name is already registered. */
|
||||
registerTool(tool: CodemodeTool): void {
|
||||
if (this.toolsByName.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered`);
|
||||
this.toolsByName.set(tool.name, tool);
|
||||
}
|
||||
|
||||
unregisterTool(name: string): boolean {
|
||||
return this.toolsByName.delete(name);
|
||||
}
|
||||
|
||||
get tools(): CodemodeTool[] {
|
||||
return [...this.toolsByName.values()];
|
||||
}
|
||||
|
||||
get globals(): CodemodeTool[] {
|
||||
return [...this.globalsByName.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* `code` is an async function body: `return` and top-level `await` work.
|
||||
* Never rejects for script failures; those come back as `{ ok: false }`.
|
||||
* The script can use `store(key, value)` and `load(key)` on `options.store`.
|
||||
*/
|
||||
execute(code: string, options: CodemodeExecuteOptions = {}): Promise<CodemodeResult> {
|
||||
if (this.closed) return Promise.reject(new Error("Sandbox is closed"));
|
||||
const tools = new Map(this.toolsByName);
|
||||
const execution = new Execution({
|
||||
code,
|
||||
tools: [...tools.values()],
|
||||
globals: [...this.globalsByName.values()],
|
||||
executors: { tools, globals: this.globalsByName },
|
||||
timeoutMs: options.timeoutMs ?? this.timeoutMs,
|
||||
signal: options.signal,
|
||||
memoryLimitBytes: this.memoryLimitBytes,
|
||||
store: serializeStore(options.store),
|
||||
launch: this.launch,
|
||||
});
|
||||
this.running.add(execution);
|
||||
return execution.promise.finally(() => this.running.delete(execution));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `code` deterministically, with the caller answering its calls: the same script, start time, seed, tools,
|
||||
* globals, and store, answered with the same results in the same `seq` order, makes the same calls with the same
|
||||
* arguments and produces the same output, on any VM with the same fingerprint. A host can therefore replay it
|
||||
* after a restart. The VM runs in UTC, its clock reads `startedAt` and then the `now` of the result being
|
||||
* processed, its randomness comes from `seed`, and `WeakRef` and `FinalizationRegistry` do not exist.
|
||||
*
|
||||
* The sandbox's own tools and globals are not used: the run declares its own, and every call goes to
|
||||
* `options.onCall`. Never rejects for script failures; abort through `options.signal` to end the run with an
|
||||
* error the script cannot catch.
|
||||
*/
|
||||
start(code: string, options: CodemodeRunOptions): CodemodeRun {
|
||||
if (this.closed) throw new Error("Sandbox is closed");
|
||||
if (!Number.isFinite(options.startedAt) || options.startedAt < 0) {
|
||||
throw new Error("startedAt must be a non-negative number of milliseconds since the epoch");
|
||||
}
|
||||
if (!Number.isInteger(options.seed) || options.seed < 0 || options.seed > 0xffffffff) {
|
||||
throw new Error("seed must be a 32-bit unsigned integer");
|
||||
}
|
||||
const tools = options.tools ?? [];
|
||||
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) throw new Error("Tool names must be unique");
|
||||
const scriptNames = tools.map((tool) => tool.jsName ?? toCodemodeIdentifier(tool.name));
|
||||
if (new Set(scriptNames).size !== tools.length) throw new Error("Tool script names must be unique");
|
||||
const globals = options.globals ?? [];
|
||||
checkGlobals(globals);
|
||||
const execution = new Execution({
|
||||
code,
|
||||
tools,
|
||||
globals,
|
||||
executors: { tools: new Map(), globals: new Map() },
|
||||
timeoutMs: options.timeoutMs ?? Number.POSITIVE_INFINITY,
|
||||
signal: options.signal,
|
||||
memoryLimitBytes: this.memoryLimitBytes,
|
||||
store: serializeStore(options.store),
|
||||
deterministic: { startedAt: options.startedAt, seed: options.seed },
|
||||
control: { onStarted: options.onStarted, onCall: options.onCall, onIdle: options.onIdle },
|
||||
launch: this.launch,
|
||||
});
|
||||
this.running.add(execution);
|
||||
const result = execution.promise.finally(() => this.running.delete(execution));
|
||||
return {
|
||||
result,
|
||||
deliver: (seq, now, id, outcome) => execution.deliver(seq, now, id, outcome),
|
||||
get finished() {
|
||||
return execution.finished;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Aborts in-flight executions (they resolve with `kind: "aborted"`) and rejects new ones. */
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
await Promise.all([...this.running].map((execution) => execution.abort("Sandbox closed")));
|
||||
}
|
||||
}
|
||||
88
third_party/pi/packages/codemode/src/runtime/host.ts
vendored
Normal file
88
third_party/pi/packages/codemode/src/runtime/host.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { CodemodeSandboxOptions } from "../types.ts";
|
||||
import { type CodemodeWasmModule, loadQuickJSWasm } from "../wasm.ts";
|
||||
import { CodemodeSandboxBase, errorMessage, type VmLauncher } from "./execution.ts";
|
||||
import type { WorkerData } from "./protocol.ts";
|
||||
|
||||
function defaultWorkerUrl(): URL {
|
||||
// `.ts` when running from source (tests, tsx), `.js` from the published dist.
|
||||
return new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each execution gets its own worker thread and QuickJS VM. A fresh worker per run keeps
|
||||
* termination simple: a runaway script, including one that only spins the microtask queue, is
|
||||
* killed with `terminate()` and cannot poison a later run. Before terminating, the host sets a
|
||||
* shared interrupt flag that the VM polls, because Bun's `terminate()` cannot stop a thread that
|
||||
* is spinning in wasm. Stopping requests termination without waiting for exit: some built-ins do
|
||||
* not poll the flag, so on Bun a worker can outlive the script's result.
|
||||
*/
|
||||
function workerLauncher(options: CodemodeSandboxOptions): VmLauncher {
|
||||
return async (data, events) => {
|
||||
let module: CodemodeWasmModule;
|
||||
try {
|
||||
module = await (options.wasm ?? loadQuickJSWasm());
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`);
|
||||
}
|
||||
const interrupt = new SharedArrayBuffer(4);
|
||||
const workerData: WorkerData = { ...data, wasm: module, interrupt };
|
||||
const releaseWorker = options.acquireWorker?.();
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(options.workerUrl ?? defaultWorkerUrl(), { workerData });
|
||||
} catch (error) {
|
||||
releaseWorker?.();
|
||||
throw new Error(`Failed to start worker: ${errorMessage(error)}`);
|
||||
}
|
||||
if (releaseWorker) worker.once("exit", releaseWorker);
|
||||
worker.on("message", (message: unknown) => {
|
||||
// Node 24/26 --watch leaks dependency reports onto the worker channel (nodejs/node#65044).
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
("watch:import" in message || "watch:require" in message)
|
||||
)
|
||||
return;
|
||||
events.message(message);
|
||||
});
|
||||
worker.on("error", (error: unknown) => {
|
||||
events.failure({
|
||||
kind: "sandbox",
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
});
|
||||
worker.on("exit", (code) => {
|
||||
events.failure({ kind: "sandbox", message: `Worker exited with code ${code} before the script settled` });
|
||||
});
|
||||
const stop = async (): Promise<void> => {
|
||||
Atomics.store(new Int32Array(interrupt), 0, 1);
|
||||
// A non-interruptible built-in can delay Bun's terminate() until it returns (#10748).
|
||||
// Do not hold up the result while that worker exits.
|
||||
void worker.terminate().catch(() => undefined);
|
||||
// terminate() refs the worker internally; unref it after requesting termination.
|
||||
worker.unref();
|
||||
};
|
||||
try {
|
||||
options.onWorkerStarted?.(new Promise<void>((resolve) => worker.once("exit", () => resolve())));
|
||||
} catch (error) {
|
||||
void stop();
|
||||
throw error;
|
||||
}
|
||||
return { post: (message) => worker.postMessage(message), stop };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs JavaScript in a QuickJS VM (a separate wasm instance) inside a worker thread. The worker
|
||||
* keeps script execution off the host thread: QuickJS runs synchronously, so a spinning script on
|
||||
* the host thread would block its event loop. Requires `node:worker_threads` (Node or Bun); see
|
||||
* `InlineCodemodeSandbox` and `RemoteCodemodeSandbox` in `@earendil-works/pi-codemode/portable`
|
||||
* for other runtimes.
|
||||
*/
|
||||
export class CodemodeSandbox extends CodemodeSandboxBase {
|
||||
constructor(options: CodemodeSandboxOptions = {}) {
|
||||
super(options, workerLauncher(options));
|
||||
}
|
||||
}
|
||||
126
third_party/pi/packages/codemode/src/runtime/inline.ts
vendored
Normal file
126
third_party/pi/packages/codemode/src/runtime/inline.ts
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Runs the VM on the calling thread, for runtimes without worker threads (Cloudflare Workers and
|
||||
* Durable Objects, browsers) and for the server side of `RemoteCodemodeSandbox`.
|
||||
*
|
||||
* Problem: QuickJS runs synchronously. While a script computes, the host's event loop does not
|
||||
* run, so neither the deadline timer nor an abort signal can fire. On Workers even `Date.now()` is
|
||||
* frozen during synchronous execution, so the VM cannot check a clock either. Example: with a
|
||||
* worker thread, `while (true) {}` is stopped by the host's 200 ms timer; on the calling thread
|
||||
* that timer never gets a chance to run.
|
||||
*
|
||||
* Solution: an interrupt budget. QuickJS polls its interrupt handler periodically while a script
|
||||
* runs; this launcher counts the polls across the whole execution and interrupts the script once
|
||||
* the count passes the budget. The execution then fails with `kind: "timeout"`. The deadline and
|
||||
* abort signal still work whenever the script waits on a tool, since the event loop runs then.
|
||||
*
|
||||
* The rate of polls depends on the code and the machine. Measured with a tight loop: about 50,000
|
||||
* per second of CPU on an Apple M-series laptop and about 10,000 per second on Cloudflare Durable
|
||||
* Objects (October 2026). The default of 100,000 allows roughly 2 to 10 seconds of uninterrupted
|
||||
* computation; keep it well below the platform's own CPU limit, because hitting that limit resets
|
||||
* a Durable Object instead of failing one script.
|
||||
*
|
||||
* Once the VM reports `done` or `crash`, the result is decided, so the VM is interrupted at the
|
||||
* next poll instead of running on until the host stops it. Example: a script that keeps printing in
|
||||
* a `for (;;)` loop after its output limit was reached would otherwise run until the budget ran out
|
||||
* and be reported as a timeout.
|
||||
*
|
||||
* Stack: the VM runs on the caller's stack, which is smaller than a worker thread's (about 1 MB
|
||||
* on Node's main thread), so QuickJS's stack guard is set to {@link INLINE_MAX_STACK_SIZE}.
|
||||
*
|
||||
* Reentrancy: VM callbacks run inside QuickJS. Messages from the VM are delivered to the host in
|
||||
* a microtask, after the VM has returned, so host code (tool calls, `finish()`, disposal) never
|
||||
* runs inside a VM callback and never re-enters the VM.
|
||||
*/
|
||||
import type { CodemodeSandboxBaseOptions } from "../types.ts";
|
||||
import type { CodemodeWasmModule } from "../wasm.ts";
|
||||
import { CodemodeSandboxBase, errorMessage, type VmLauncher } from "./execution.ts";
|
||||
import { startVm } from "./vm.ts";
|
||||
|
||||
export const DEFAULT_INTERRUPT_BUDGET = 100_000;
|
||||
|
||||
/** Allows recursion about 1,000 calls deep and trips well before a 1 MB host stack overflows. */
|
||||
export const INLINE_MAX_STACK_SIZE = 256 * 1024;
|
||||
|
||||
export interface InlineCodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
|
||||
/**
|
||||
* Compiled `quickjs-wasi/quickjs.wasm`. On Cloudflare, import it with Wrangler's `CompiledWasm`
|
||||
* rule (`@earendil-works/pi-codemode/cloudflare` does this); on Node, `loadQuickJSWasm()`.
|
||||
*/
|
||||
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>;
|
||||
/**
|
||||
* Interrupt polls an execution may use before it fails with `kind: "timeout"`. The only limit on
|
||||
* a script that computes without awaiting a tool. Default: {@link DEFAULT_INTERRUPT_BUDGET}.
|
||||
*/
|
||||
interruptBudget?: number;
|
||||
}
|
||||
|
||||
export function inlineLauncher(
|
||||
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>,
|
||||
interruptBudget: number = DEFAULT_INTERRUPT_BUDGET,
|
||||
): VmLauncher {
|
||||
return async (data, events) => {
|
||||
let module: CodemodeWasmModule;
|
||||
try {
|
||||
module = await wasm;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`);
|
||||
}
|
||||
let stopped = false;
|
||||
let settled = false;
|
||||
let polls = 0;
|
||||
let exhausted = false;
|
||||
const session = await startVm({
|
||||
data,
|
||||
wasm: module,
|
||||
maxStackSize: INLINE_MAX_STACK_SIZE,
|
||||
interruptBudget,
|
||||
shouldInterrupt: () => {
|
||||
if (stopped || settled || exhausted) return true;
|
||||
if (++polls <= interruptBudget) return false;
|
||||
exhausted = true;
|
||||
return true;
|
||||
},
|
||||
post: (message) => {
|
||||
const terminal = message.type === "done" || message.type === "crash";
|
||||
// Only the first terminal message counts: a crash caused by interrupting a settled VM is noise.
|
||||
if (terminal && settled) return;
|
||||
if (terminal) settled = true;
|
||||
queueMicrotask(() => {
|
||||
if (stopped) return;
|
||||
// The interrupt surfaces as a crash or a failed done; report what caused it instead.
|
||||
if (exhausted && (message.type === "done" || message.type === "crash")) {
|
||||
events.failure({
|
||||
kind: "timeout",
|
||||
message: `Execution exceeded its interrupt budget of ${interruptBudget}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
events.message(message);
|
||||
});
|
||||
},
|
||||
});
|
||||
return {
|
||||
post: (message) => {
|
||||
if (!stopped) session.deliver(message);
|
||||
},
|
||||
stop: async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
session.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs each execution's QuickJS VM on the calling thread. Works in any runtime with WebAssembly
|
||||
* and microtasks; needs no worker threads, files, or Node built-ins. A script that computes
|
||||
* blocks the caller's thread until it awaits a tool or uses up its interrupt budget, so prefer
|
||||
* `CodemodeSandbox` where worker threads exist, and `RemoteCodemodeSandbox` to keep scripts off a
|
||||
* thread that must stay responsive (such as a Durable Object that serves a session).
|
||||
*/
|
||||
export class InlineCodemodeSandbox extends CodemodeSandboxBase {
|
||||
constructor(options: InlineCodemodeSandboxOptions) {
|
||||
super(options, inlineLauncher(options.wasm, options.interruptBudget));
|
||||
}
|
||||
}
|
||||
522
third_party/pi/packages/codemode/src/runtime/prelude-source.ts
vendored
Normal file
522
third_party/pi/packages/codemode/src/runtime/prelude-source.ts
vendored
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* JavaScript evaluated inside the QuickJS VM before the script runs.
|
||||
*
|
||||
* The VM is its own wasm instance, so nothing here guards a realm boundary.
|
||||
* The prelude keeps the host bridge in a closure so the script cannot call it
|
||||
* directly, and builds `tools`, `ALL_TOOLS`, the output helpers (`text`, `image`,
|
||||
* `exit`, `console`), and globals on top of it. Tool arguments and results cross
|
||||
* as JSON strings and are parsed on this side.
|
||||
*
|
||||
* Output helpers: `text(value)` appends a text item (non-strings are
|
||||
* JSON-stringified), `image(urlOrItem)` appends an image from a base64 `data:` URL, an
|
||||
* `{ image_url }` object, or an image block `{ type: "image", data, mimeType }`, and `exit()` ends the script
|
||||
* successfully. `console.*` appends text items marked as console output.
|
||||
*
|
||||
* `store(key, value)` and `load(key)` are synchronous: they work on a snapshot of
|
||||
* JSON text passed in as `storeJson`, and the keys the script wrote are reported
|
||||
* with a successful "done".
|
||||
*
|
||||
* Evaluates to a function `(bridge, toolsJson, globalsJson, storeJson) => { settle, run, stalled }`.
|
||||
* `settle(id, ok, payload, propertiesJson)` settles call `id`; a rejection's `Error` gets the own properties of the
|
||||
* `propertiesJson` object, except `name`, `message`, and `stack`.
|
||||
* `toolsJson` lists `{ name, jsName, description }`: `tools[jsName]` and `tools[name]` call the
|
||||
* tool, and `ALL_TOOLS` lists `{ name: jsName, description }`.
|
||||
* `stalled()` reports a script that has not finished while no host call is pending: with no timers
|
||||
* or I/O in the VM, nothing can ever resume it.
|
||||
* `globalsJson` lists `{ name, spread }`; `a.b` names are grouped into a frozen `a` object.
|
||||
* `bridge(kind, a, b, c)` with kind "call" or "global" (id, name, argsJson),
|
||||
* "output" ("text" or "console", text) or ("image", data, mimeType), or "done" (ok, valueJsonOrErrorJson, writesJson).
|
||||
*
|
||||
* Before anything else the prelude freezes the built-ins and makes the built-in globals read-only.
|
||||
* The prelude shares them with the script, so a script that patched one (for example
|
||||
* `Array.prototype.toJSON`) could otherwise corrupt what the prelude reports to the host.
|
||||
*/
|
||||
export const MAX_STORE_VALUE_CHARS = 256 * 1024;
|
||||
export const MAX_STORE_TOTAL_CHARS = 1024 * 1024;
|
||||
/**
|
||||
* Output one script may produce with `text()`, `image()`, and `console.*`: characters of text and
|
||||
* base64 image data, and items. The host keeps all output until the script ends, so without a
|
||||
* limit a script that prints in a loop grows the host's memory until it crashes. The item limit
|
||||
* covers loops that print empty strings.
|
||||
*/
|
||||
export const MAX_OUTPUT_CHARS = 16 * 1024 * 1024;
|
||||
export const MAX_OUTPUT_ITEMS = 100_000;
|
||||
|
||||
const IMAGE_HELPER_EXPECTS =
|
||||
'image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }';
|
||||
|
||||
export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson, storeJson) {
|
||||
"use strict";
|
||||
|
||||
// Freeze every object reachable from the built-in globals, plus the intrinsics that are only
|
||||
// reachable from instances (iterator and generator prototypes, %TypedArray%).
|
||||
//
|
||||
// Freezing alone breaks ordinary code through the "override mistake": a data property that is
|
||||
// read-only on a prototype cannot be assigned on an instance either, so
|
||||
// this.name = "MyError" in an Error subclass would throw. Commonly overridden properties
|
||||
// become accessors whose setter defines an own property on the instance instead.
|
||||
(function lockdown() {
|
||||
const OVERRIDABLE = new Set(["constructor", "name", "message", "toString", "toLocaleString", "valueOf", "toJSON"]);
|
||||
const seen = new Set([globalThis]);
|
||||
const queue = [];
|
||||
const add = (value) => {
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
if (!seen.has(value)) {
|
||||
seen.add(value);
|
||||
queue.push(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Accessors from an object literal have no own prototype object, unlike function expressions,
|
||||
// whose prototype.constructor would be converted again without end.
|
||||
function allowOverride(object, key, value, enumerable) {
|
||||
const { get, set } = Object.getOwnPropertyDescriptor(
|
||||
{
|
||||
get accessor() {
|
||||
return value;
|
||||
},
|
||||
set accessor(next) {
|
||||
if (this === object) {
|
||||
throw new TypeError("Cannot assign to read only property '" + String(key) + "' of a built-in");
|
||||
}
|
||||
if ((typeof this !== "object" || this === null) && typeof this !== "function") return;
|
||||
Object.defineProperty(this, key, { value: next, writable: true, enumerable: true, configurable: true });
|
||||
},
|
||||
},
|
||||
"accessor",
|
||||
);
|
||||
Object.defineProperty(object, key, { get, set, enumerable, configurable: false });
|
||||
add(get);
|
||||
add(set);
|
||||
}
|
||||
|
||||
for (const key of Reflect.ownKeys(globalThis)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
add(descriptor.value);
|
||||
add(descriptor.get);
|
||||
add(descriptor.set);
|
||||
if ("value" in descriptor && descriptor.configurable) {
|
||||
Object.defineProperty(globalThis, key, { writable: false, configurable: false });
|
||||
}
|
||||
}
|
||||
add(Object.getPrototypeOf(function* () {}));
|
||||
add(Object.getPrototypeOf(async function () {}));
|
||||
add(Object.getPrototypeOf(async function* () {}));
|
||||
add(Object.getPrototypeOf(Int8Array));
|
||||
add(Object.getPrototypeOf([][Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(new Map()[Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(new Set()[Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(""[Symbol.iterator]()));
|
||||
add(Object.getPrototypeOf(/a/[Symbol.matchAll]("")));
|
||||
if (typeof Iterator === "function") {
|
||||
if (typeof Iterator.prototype.map === "function") add(Object.getPrototypeOf([].values().map((x) => x)));
|
||||
if (typeof Iterator.from === "function") add(Object.getPrototypeOf(Iterator.from({ next() {} })));
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const object = queue.pop();
|
||||
add(Object.getPrototypeOf(object));
|
||||
const descriptors = Object.getOwnPropertyDescriptors(object);
|
||||
for (const key of Reflect.ownKeys(descriptors)) {
|
||||
const descriptor = descriptors[key];
|
||||
if ("value" in descriptor) {
|
||||
add(descriptor.value);
|
||||
if (
|
||||
descriptor.writable &&
|
||||
descriptor.configurable &&
|
||||
(object === Object.prototype || OVERRIDABLE.has(key))
|
||||
) {
|
||||
allowOverride(object, key, descriptor.value, descriptor.enumerable);
|
||||
}
|
||||
} else {
|
||||
add(descriptor.get);
|
||||
add(descriptor.set);
|
||||
}
|
||||
}
|
||||
Object.freeze(object);
|
||||
}
|
||||
})();
|
||||
const stringify = JSON.stringify;
|
||||
const parse = JSON.parse;
|
||||
const promiseThen = Promise.prototype.then;
|
||||
const ErrorCtor = Error;
|
||||
const TypeErrorCtor = TypeError;
|
||||
const RangeErrorCtor = RangeError;
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
let finished = false;
|
||||
// Thrown by exit() to unwind the script after it already reported success.
|
||||
const EXIT = Object.freeze({});
|
||||
|
||||
function done(ok, payload, writes) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
bridge("done", ok, payload, writes);
|
||||
}
|
||||
|
||||
function serialize(value) {
|
||||
return value === undefined ? undefined : stringify(value);
|
||||
}
|
||||
|
||||
// QuickJS stacks list frames only. Prefix "Name: message" like V8 so the
|
||||
// text reads the same as a Node error, and drop this prelude's frames.
|
||||
function errorText(error) {
|
||||
const head = error.message ? error.name + ": " + error.message : String(error.name);
|
||||
const frames =
|
||||
typeof error.stack === "string"
|
||||
? error.stack.split("\\n").filter((line) => line.trim() && !line.includes("codemode-prelude.js"))
|
||||
: [];
|
||||
return [head, ...frames].join("\\n");
|
||||
}
|
||||
|
||||
function format(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof ErrorCtor) return errorText(value);
|
||||
try {
|
||||
const json = stringify(value);
|
||||
return json === undefined ? String(value) : json;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// The host requires string fields. A script can set an error's name or message to anything, so
|
||||
// they are coerced, and a value whose coercion throws gets a placeholder.
|
||||
function describeError(error) {
|
||||
try {
|
||||
if (error instanceof ErrorCtor) {
|
||||
return stringify({ name: String(error.name), message: String(error.message), stack: errorText(error) });
|
||||
}
|
||||
return stringify({ message: String(format(error)) });
|
||||
} catch {
|
||||
return stringify({ message: "The script threw a value that cannot be described" });
|
||||
}
|
||||
}
|
||||
|
||||
function caller(kind, name, spread) {
|
||||
return (...args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let json;
|
||||
try {
|
||||
json = serialize(spread ? args : args[0]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const id = nextId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
bridge(kind, id, name, json);
|
||||
});
|
||||
}
|
||||
|
||||
const tools = Object.create(null);
|
||||
const allTools = [];
|
||||
for (const { name, jsName, description } of parse(toolsJson)) {
|
||||
const fn = caller("call", name);
|
||||
// The first tool wins when two names normalize to the same identifier.
|
||||
if (!(jsName in tools)) {
|
||||
tools[jsName] = fn;
|
||||
allTools.push(Object.freeze({ name: jsName, description }));
|
||||
}
|
||||
if (!(name in tools)) tools[name] = fn;
|
||||
}
|
||||
Object.freeze(tools);
|
||||
Object.freeze(allTools);
|
||||
|
||||
// Reading a member that does not exist throws an error that names the close matches, instead of
|
||||
// a later "not a function". \`in\` checks still work.
|
||||
const comparable = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
function guard(target, label, names, hint) {
|
||||
return new Proxy(target, {
|
||||
get(object, property, receiver) {
|
||||
if (typeof property !== "string" || property in object || property in Object.prototype || property === "then" || property === "toJSON") {
|
||||
return Reflect.get(object, property, receiver);
|
||||
}
|
||||
const wanted = comparable(property);
|
||||
const exact = names.filter((name) => comparable(name) === wanted);
|
||||
const close = exact.length > 0 ? exact : names.filter((name) => wanted && (comparable(name).includes(wanted) || wanted.includes(comparable(name))));
|
||||
let message = label + "." + property + " does not exist.";
|
||||
if (close.length > 0) message += " Did you mean " + close.slice(0, 5).map((name) => label + "." + name).join(", ") + "?";
|
||||
else if (names.length <= 20) message += " Available: " + names.join(", ") + ".";
|
||||
if (hint) message += " " + hint;
|
||||
message += ' Check for a member with "' + property + '" in ' + label + ".";
|
||||
throw new TypeErrorCtor(message);
|
||||
},
|
||||
});
|
||||
}
|
||||
const toolsProxy = guard(
|
||||
tools,
|
||||
"tools",
|
||||
allTools.map((tool) => tool.name),
|
||||
"ALL_TOOLS lists every tool; searchTools(query) finds tools by topic.",
|
||||
);
|
||||
|
||||
const namespaces = new Map();
|
||||
for (const { name, spread } of parse(globalsJson)) {
|
||||
const fn = caller("global", name, spread);
|
||||
const dot = name.indexOf(".");
|
||||
if (dot === -1) {
|
||||
Object.defineProperty(globalThis, name, { value: fn, enumerable: true });
|
||||
continue;
|
||||
}
|
||||
const namespace = name.slice(0, dot);
|
||||
if (!namespaces.has(namespace)) namespaces.set(namespace, Object.create(null));
|
||||
namespaces.get(namespace)[name.slice(dot + 1)] = fn;
|
||||
}
|
||||
for (const [namespace, members] of namespaces) {
|
||||
Object.freeze(members);
|
||||
const value = guard(members, namespace, Object.keys(members));
|
||||
Object.defineProperty(globalThis, namespace, { value, enumerable: true });
|
||||
}
|
||||
|
||||
// key -> JSON text. Sizes count key and JSON characters.
|
||||
const stored = new Map(Object.entries(parse(storeJson)));
|
||||
const writes = new Map();
|
||||
let storedChars = 0;
|
||||
for (const [key, json] of stored) storedChars += key.length + json.length;
|
||||
|
||||
const STORE_HINT =
|
||||
"store() is for small state such as IDs or summaries. Show images with image(), keep large data in variables, or write it to a file with a tool.";
|
||||
|
||||
function checkKey(name, key) {
|
||||
if (typeof key !== "string") throw new TypeError(name + "() key must be a string");
|
||||
}
|
||||
|
||||
function store(key, value) {
|
||||
checkKey("store", key);
|
||||
const previous = stored.has(key) ? key.length + stored.get(key).length : 0;
|
||||
if (value === undefined) {
|
||||
stored.delete(key);
|
||||
storedChars -= previous;
|
||||
writes.set(key, undefined);
|
||||
return;
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = stringify(value);
|
||||
} catch (error) {
|
||||
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable: " + format(error));
|
||||
}
|
||||
if (json === undefined) {
|
||||
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable");
|
||||
}
|
||||
if (json.length > ${MAX_STORE_VALUE_CHARS}) {
|
||||
throw new RangeError(
|
||||
"store(" + stringify(key) + ") value has " + json.length + " characters of JSON, more than the limit of ${MAX_STORE_VALUE_CHARS}. " +
|
||||
STORE_HINT,
|
||||
);
|
||||
}
|
||||
const next = storedChars - previous + key.length + json.length;
|
||||
if (next > ${MAX_STORE_TOTAL_CHARS}) {
|
||||
throw new RangeError(
|
||||
"store is full: stored values would exceed ${MAX_STORE_TOTAL_CHARS} characters of JSON. Delete keys with store(key, undefined). " +
|
||||
STORE_HINT,
|
||||
);
|
||||
}
|
||||
stored.set(key, json);
|
||||
storedChars = next;
|
||||
writes.set(key, json);
|
||||
}
|
||||
|
||||
function load(key) {
|
||||
checkKey("load", key);
|
||||
const json = stored.get(key);
|
||||
return json === undefined ? undefined : parse(json);
|
||||
}
|
||||
|
||||
function serializeWrites() {
|
||||
const entries = [];
|
||||
for (const [key, json] of writes) entries.push(json === undefined ? [key] : [key, json]);
|
||||
return stringify(entries);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "store", { value: store, enumerable: true });
|
||||
Object.defineProperty(globalThis, "load", { value: load, enumerable: true });
|
||||
|
||||
let outputChars = 0;
|
||||
let outputItems = 0;
|
||||
|
||||
// Past the output limits the script fails: done() reports the error, so catching it does not
|
||||
// resume output, and the host ends the script.
|
||||
function output(kind, data, mimeType) {
|
||||
if (finished) return;
|
||||
outputChars += data.length;
|
||||
outputItems++;
|
||||
if (outputChars > ${MAX_OUTPUT_CHARS} || outputItems > ${MAX_OUTPUT_ITEMS}) {
|
||||
const error = new RangeErrorCtor(
|
||||
"script output exceeded the limit of ${MAX_OUTPUT_CHARS} characters or ${MAX_OUTPUT_ITEMS} text(), image(), and console calls. " +
|
||||
"Print a summary instead, or write large data to a file with a tool.",
|
||||
);
|
||||
done(false, describeError(error));
|
||||
throw error;
|
||||
}
|
||||
bridge("output", kind, data, mimeType);
|
||||
}
|
||||
|
||||
// Primitives become their string form, everything else JSON.
|
||||
function outputText(value) {
|
||||
if (value === undefined || value === null || typeof value !== "object" && typeof value !== "function") {
|
||||
return String(value);
|
||||
}
|
||||
const json = stringify(value);
|
||||
return json === undefined ? String(value) : json;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
let rendered;
|
||||
try {
|
||||
rendered = outputText(value);
|
||||
} catch (error) {
|
||||
throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error));
|
||||
}
|
||||
output("text", rendered);
|
||||
}
|
||||
|
||||
function imageUrl(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
}
|
||||
if (value.image_url !== undefined) {
|
||||
if (typeof value.image_url !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
return value.image_url;
|
||||
}
|
||||
if (typeof value.type !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
if (value.type !== "image") {
|
||||
throw new TypeErrorCtor('image only accepts image blocks, got "' + value.type + '"');
|
||||
}
|
||||
if (typeof value.data !== "string" || value.data === "") throw new TypeErrorCtor("image expected image block data");
|
||||
if (value.data.toLowerCase().startsWith("data:")) return value.data;
|
||||
return "data:;base64," + value.data;
|
||||
}
|
||||
|
||||
// Base64 of the signatures of the formats providers accept inline (PNG, JPEG except
|
||||
// JPEG-LS, GIF, "RIFF....WEBP"). Signatures start at byte 0, so their encodings are prefixes.
|
||||
const IMAGE_SIGNATURES = [
|
||||
["image/png", /^iVBORw0KGg/],
|
||||
["image/jpeg", /^[/]9j[/](?!9)/],
|
||||
["image/gif", /^R0lGOD[dl]h/],
|
||||
["image/webp", /^UklG.{8}RUJQ/],
|
||||
];
|
||||
|
||||
function image(value) {
|
||||
const url = imageUrl(value);
|
||||
if (url === "") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
|
||||
const colon = url.indexOf(":");
|
||||
const scheme = colon === -1 ? "" : url.slice(0, colon).toLowerCase();
|
||||
if (scheme === "http" || scheme === "https") {
|
||||
throw new TypeErrorCtor("remote image URLs are not supported in tool outputs. Pass a base64 data URI instead");
|
||||
}
|
||||
const comma = url.indexOf(",");
|
||||
const header = comma === -1 ? [] : url.slice(colon + 1, comma).split(";");
|
||||
if (scheme !== "data" || comma === -1 || header.slice(1).every((part) => part.toLowerCase() !== "base64")) {
|
||||
throw new TypeErrorCtor("invalid image output. Pass a base64 data URI instead");
|
||||
}
|
||||
// Providers reject the whole request on a bad image, and a persisted image block would be
|
||||
// resent on every later turn. Line breaks from wrapped base64 are dropped. The declared type
|
||||
// is ignored in favor of the detected one, as providers also reject mismatches.
|
||||
const data = url.slice(comma + 1).replace(/\\s+/g, "");
|
||||
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
||||
throw new TypeErrorCtor("invalid image output. The image data is not valid base64 (truncated or corrupted?)");
|
||||
}
|
||||
const head = data.slice(0, 16);
|
||||
const signature = IMAGE_SIGNATURES.find(([, pattern]) => pattern.test(head));
|
||||
if (!signature) {
|
||||
throw new TypeErrorCtor("invalid image output. The image data is not a PNG, JPEG, GIF, or WebP image");
|
||||
}
|
||||
output("image", data, signature[0]);
|
||||
}
|
||||
|
||||
function exit() {
|
||||
let writesJson;
|
||||
try {
|
||||
writesJson = serializeWrites();
|
||||
} catch (error) {
|
||||
done(false, describeError(error));
|
||||
throw EXIT;
|
||||
}
|
||||
done(true, undefined, writesJson);
|
||||
throw EXIT;
|
||||
}
|
||||
|
||||
const console = {};
|
||||
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
||||
console[level] = (...args) => {
|
||||
output("console", args.map(format).join(" "));
|
||||
};
|
||||
}
|
||||
Object.freeze(console);
|
||||
|
||||
Object.defineProperty(globalThis, "tools", { value: toolsProxy, enumerable: true });
|
||||
Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
|
||||
Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
|
||||
Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
|
||||
Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
|
||||
Object.defineProperty(globalThis, "exit", { value: exit, enumerable: true });
|
||||
|
||||
return {
|
||||
settle(id, ok, payload, propertiesJson) {
|
||||
const entry = pending.get(id);
|
||||
if (!entry) return;
|
||||
pending.delete(id);
|
||||
if (!ok) {
|
||||
const error = new ErrorCtor(payload);
|
||||
if (propertiesJson !== undefined) {
|
||||
const properties = parse(propertiesJson);
|
||||
for (const key of Object.keys(properties)) {
|
||||
if (key === "name" || key === "message" || key === "stack") continue;
|
||||
Object.defineProperty(error, key, { value: properties[key], writable: true, enumerable: true, configurable: true });
|
||||
}
|
||||
}
|
||||
entry.reject(error);
|
||||
return;
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = payload === undefined ? undefined : parse(payload);
|
||||
} catch (error) {
|
||||
entry.reject(error);
|
||||
return;
|
||||
}
|
||||
entry.resolve(value);
|
||||
},
|
||||
run(fn) {
|
||||
let promise;
|
||||
try {
|
||||
promise = fn(toolsProxy, console);
|
||||
} catch (error) {
|
||||
done(false, describeError(error));
|
||||
return;
|
||||
}
|
||||
promiseThen.call(
|
||||
promise,
|
||||
(value) => {
|
||||
let json;
|
||||
try {
|
||||
json = serialize(value);
|
||||
} catch (error) {
|
||||
done(false, describeError(error));
|
||||
return;
|
||||
}
|
||||
done(true, json, serializeWrites());
|
||||
},
|
||||
(error) => {
|
||||
done(false, describeError(error));
|
||||
},
|
||||
);
|
||||
},
|
||||
stalled() {
|
||||
if (finished || pending.size > 0) return false;
|
||||
done(
|
||||
false,
|
||||
stringify({
|
||||
name: "Error",
|
||||
message:
|
||||
"The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
})`;
|
||||
93
third_party/pi/packages/codemode/src/runtime/protocol.ts
vendored
Normal file
93
third_party/pi/packages/codemode/src/runtime/protocol.ts
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { CodemodeDuring, CodemodeOutputItem } from "../types.ts";
|
||||
import type { CodemodeWasmModule } from "../wasm.ts";
|
||||
|
||||
/**
|
||||
* Messages between the host (main thread) and the worker. Tool arguments,
|
||||
* results, and values cross as JSON strings: the worker passes them into and
|
||||
* out of the QuickJS VM as strings and never builds structured values itself.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Everything a VM needs to run one script, independent of where it runs. Plain data, so it can
|
||||
* cross a `postMessage` or a Workers RPC call unchanged.
|
||||
*/
|
||||
export interface VmStartData {
|
||||
code: string;
|
||||
/** `jsName` is the identifier the script uses; `description` is listed in `ALL_TOOLS`. */
|
||||
tools: { name: string; jsName: string; description: string }[];
|
||||
globals: { name: string; spread: boolean }[];
|
||||
memoryLimitBytes: number | undefined;
|
||||
/** Snapshot for `load()`: key to JSON text. */
|
||||
store: Record<string, string>;
|
||||
/**
|
||||
* Run deterministically (`CodemodeSandboxBase.start()`): UTC, a virtual clock starting at `startedAt` (ms since
|
||||
* the epoch) that moves only with delivered results, randomness from `seed`, no `WeakRef`, results applied in
|
||||
* `seq` order, and `started`/`idle` messages.
|
||||
*/
|
||||
deterministic?: { startedAt: number; seed: number };
|
||||
}
|
||||
|
||||
export interface WorkerData extends VmStartData {
|
||||
/** Compiled `quickjs-wasi` module. Structured clone shares the compiled code with the worker. */
|
||||
wasm: CodemodeWasmModule;
|
||||
/**
|
||||
* One Int32 the host sets to non-zero before terminating the worker. The VM's interrupt handler
|
||||
* polls it, because Bun's `worker.terminate()` cannot stop a thread that is spinning in wasm.
|
||||
*/
|
||||
interrupt: SharedArrayBuffer;
|
||||
}
|
||||
|
||||
/** JSON-encoded `{ name?, message, stack? }` of an error thrown by the script. */
|
||||
export type ScriptErrorJson = string;
|
||||
|
||||
export type WorkerToHostMessage =
|
||||
| { type: "call"; id: number; target: "tool" | "global"; name: string; args: string | undefined }
|
||||
| { type: "output"; item: CodemodeOutputItem }
|
||||
/**
|
||||
* `writes` is a JSON array of `[key, json]` for `store()` and `[key]` for deletions. Deterministic mode: `during`
|
||||
* is the result the VM was processing when the script finished (0: its first run) and the calls issued by then.
|
||||
*/
|
||||
| { type: "done"; ok: true; value: string | undefined; writes: string; during?: CodemodeDuring }
|
||||
| { type: "done"; ok: false; error: ScriptErrorJson; during?: CodemodeDuring }
|
||||
/** The VM failed outside the script's control, for example a wasm trap. */
|
||||
| { type: "crash"; message: string }
|
||||
/** Deterministic mode, before the script runs: what the VM is, so a replay can refuse a different one. */
|
||||
| { type: "started"; fingerprint: string }
|
||||
/**
|
||||
* Deterministic mode, after the script waits again: having processed result `seq` (0: the script's first run), it
|
||||
* has issued `issued` calls in total.
|
||||
*/
|
||||
| { type: "idle"; seq: number; issued: number };
|
||||
|
||||
export type HostToWorkerMessage =
|
||||
/**
|
||||
* `payload` is the JSON result when `ok`, otherwise the error message. Deterministic mode: `seq` numbers the
|
||||
* results from 1 in the order the VM applies them, and the VM's clock reads `now` while it processes this one.
|
||||
*/
|
||||
{
|
||||
type: "result";
|
||||
id: number;
|
||||
ok: boolean;
|
||||
payload: string | undefined;
|
||||
seq?: number;
|
||||
now?: number;
|
||||
/** Deterministic mode, `ok: false`: a JSON object whose properties the script's `Error` gets. */
|
||||
properties?: string;
|
||||
};
|
||||
|
||||
export function isWorkerToHostMessage(value: unknown): value is WorkerToHostMessage {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const type = (value as { type?: unknown }).type;
|
||||
return (
|
||||
type === "call" ||
|
||||
type === "output" ||
|
||||
type === "done" ||
|
||||
type === "crash" ||
|
||||
type === "started" ||
|
||||
type === "idle"
|
||||
);
|
||||
}
|
||||
|
||||
export function isHostToWorkerMessage(value: unknown): value is HostToWorkerMessage {
|
||||
return typeof value === "object" && value !== null && (value as { type?: unknown }).type === "result";
|
||||
}
|
||||
277
third_party/pi/packages/codemode/src/runtime/remote.ts
vendored
Normal file
277
third_party/pi/packages/codemode/src/runtime/remote.ts
vendored
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Runs the VM in another isolate, reached over an RPC boundary such as a Cloudflare Durable Object
|
||||
* stub, while tools keep running in the caller.
|
||||
*
|
||||
* Problem: a VM on the calling thread blocks that thread while the script computes. In a Durable
|
||||
* Object that serves an agent session, a CPU-heavy script would stall every other request to the
|
||||
* session (including the user's abort) for seconds. Measured on Cloudflare (October 2026), a
|
||||
* script that computed for 10 seconds delayed the session's other requests by 10 seconds when it
|
||||
* ran in the session object or in a Dynamic Worker, but by about 50 ms when it ran in a Durable
|
||||
* Object of a separately deployed Worker.
|
||||
*
|
||||
* Solution: the caller keeps the host role (`RemoteCodemodeSandbox`: tools, call records,
|
||||
* deadline, abort) and the remote side only runs the VM (`serveCodemodeRemote`, behind
|
||||
* `CodemodeSandboxDurableObject` in `@earendil-works/pi-codemode/cloudflare`).
|
||||
*
|
||||
* Protocol, one RPC call per execution:
|
||||
*
|
||||
* client remote
|
||||
* remote.execute(request, exchange) ───────▶ starts the VM (in-thread, with interrupt budget)
|
||||
* ◀─────── exchange([...outputs, call]) one call per tool call
|
||||
* runs the tool, replies with result ──────▶ VM continues
|
||||
* ◀─────── exchange([...outputs, done]) terminal, reply ignored
|
||||
* execute() resolves ◀────────────────────── VM disposed
|
||||
*
|
||||
* `exchange` is a function the client passes in; over Workers RPC it becomes a stub that calls back
|
||||
* into the client. Output is buffered on the remote side and sent with the next call or the
|
||||
* terminal message, so a script that prints in a loop does not make one RPC per line. Each
|
||||
* `exchange` carries at most one call, so concurrent tool calls are concurrent RPCs. Calls on one
|
||||
* stub are delivered in order, which keeps output ordered before the call or result that follows.
|
||||
*
|
||||
* Termination: the client cannot interrupt a remote VM that is computing. When the client finishes
|
||||
* first (deadline, abort, `close()`), it answers every pending and later `exchange` with
|
||||
* `{ type: "stop" }`, and the remote stops the VM at that point. A script that computes without
|
||||
* calling a tool runs until its interrupt budget is used up. The remote reports that as a
|
||||
* `failure` message, which the client turns into the same `kind: "timeout"` error an in-thread
|
||||
* VM produces.
|
||||
*
|
||||
* Failure: if the RPC call rejects (the remote isolate crashed, hit its CPU limit, or was
|
||||
* redeployed) or returns without a terminal message, the execution fails with `kind: "sandbox"`.
|
||||
*/
|
||||
import type { CodemodeError, CodemodeSandboxBaseOptions } from "../types.ts";
|
||||
import type { CodemodeWasmModule } from "../wasm.ts";
|
||||
import { CodemodeSandboxBase, errorMessage, type VmChannel, type VmLauncher } from "./execution.ts";
|
||||
import { DEFAULT_INTERRUPT_BUDGET, inlineLauncher } from "./inline.ts";
|
||||
import {
|
||||
type HostToWorkerMessage,
|
||||
isWorkerToHostMessage,
|
||||
type VmStartData,
|
||||
type WorkerToHostMessage,
|
||||
} from "./protocol.ts";
|
||||
|
||||
export interface CodemodeRemoteRequest {
|
||||
data: VmStartData;
|
||||
/** Requested interrupt budget. The remote may lower it, see {@link CodemodeServeOptions}. */
|
||||
interruptBudget?: number;
|
||||
}
|
||||
|
||||
/** What the remote sends to the client: VM messages, or a failure outside the VM bridge. */
|
||||
export type CodemodeRemoteMessage = WorkerToHostMessage | { type: "failure"; error: CodemodeError };
|
||||
|
||||
/**
|
||||
* The client's answer to an exchange: the result of the call the exchange carried, `null` when it
|
||||
* carried no call, or `stop` once the client no longer wants the execution.
|
||||
*/
|
||||
export type CodemodeRemoteReply = HostToWorkerMessage | { type: "stop" } | null;
|
||||
|
||||
export type CodemodeRemoteExchange = (messages: CodemodeRemoteMessage[]) => Promise<CodemodeRemoteReply>;
|
||||
|
||||
/**
|
||||
* The remote end, usually a Durable Object stub for `CodemodeSandboxDurableObject`. Anything with
|
||||
* this method works, for example an in-process object in tests.
|
||||
*/
|
||||
export interface CodemodeRemote {
|
||||
execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RemoteCodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
|
||||
/**
|
||||
* Returns the remote that runs one execution, called once per `execute()`. For a Durable Object,
|
||||
* pick the instance here, for example `() => env.CODEMODE_SANDBOX.getByName(sessionId)`.
|
||||
*/
|
||||
remote: () => CodemodeRemote;
|
||||
/** Interrupt budget requested from the remote. Default: the remote's default. */
|
||||
interruptBudget?: number;
|
||||
}
|
||||
|
||||
/** Output buffered on the remote before it is flushed without waiting for a call. */
|
||||
const FLUSH_ITEMS = 1_000;
|
||||
const FLUSH_CHARS = 1 << 20;
|
||||
|
||||
export interface CodemodeServeOptions {
|
||||
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>;
|
||||
/** Upper bound for a request's interrupt budget, and the budget when a request names none. */
|
||||
maxInterruptBudget?: number;
|
||||
/** Upper bound for a request's VM memory, and the limit when a request names none. */
|
||||
maxMemoryLimitBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote side of {@link RemoteCodemodeSandbox}: runs one execution's VM on this thread and
|
||||
* forwards everything it reports through `exchange`. Resolves once the VM has stopped and the
|
||||
* terminal message has been delivered, or the client asked to stop. Never rejects.
|
||||
*/
|
||||
export function serveCodemodeRemote(
|
||||
request: CodemodeRemoteRequest,
|
||||
exchange: CodemodeRemoteExchange,
|
||||
options: CodemodeServeOptions,
|
||||
): Promise<void> {
|
||||
const maxBudget = options.maxInterruptBudget ?? DEFAULT_INTERRUPT_BUDGET;
|
||||
const budget = Math.min(request.interruptBudget ?? maxBudget, maxBudget);
|
||||
const requestedMemory = request.data.memoryLimitBytes;
|
||||
const maxMemory = options.maxMemoryLimitBytes;
|
||||
const memoryLimitBytes =
|
||||
requestedMemory === undefined
|
||||
? maxMemory
|
||||
: maxMemory === undefined
|
||||
? requestedMemory
|
||||
: Math.min(requestedMemory, maxMemory);
|
||||
const data: VmStartData = { ...request.data, memoryLimitBytes };
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let channel: VmChannel | undefined;
|
||||
let ended = false;
|
||||
let buffered: CodemodeRemoteMessage[] = [];
|
||||
let bufferedChars = 0;
|
||||
/** Replies that arrived before the launcher returned the channel. */
|
||||
const unsent: HostToWorkerMessage[] = [];
|
||||
|
||||
const takeBuffer = (): CodemodeRemoteMessage[] => {
|
||||
const batch = buffered;
|
||||
buffered = [];
|
||||
bufferedChars = 0;
|
||||
return batch;
|
||||
};
|
||||
const end = (terminal: CodemodeRemoteMessage | undefined): void => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
const batch = takeBuffer();
|
||||
const delivered = terminal ? exchange([...batch, terminal]).catch(() => null) : Promise.resolve();
|
||||
void delivered
|
||||
.then(() => channel?.stop())
|
||||
.catch(() => undefined)
|
||||
.then(() => resolve());
|
||||
};
|
||||
const reply = (message: CodemodeRemoteReply): void => {
|
||||
if (ended || message === null) return;
|
||||
if (message.type === "stop") {
|
||||
end(undefined);
|
||||
return;
|
||||
}
|
||||
if (channel) channel.post(message);
|
||||
else unsent.push(message);
|
||||
};
|
||||
const onMessage = (message: unknown): void => {
|
||||
if (ended) return;
|
||||
const typed = message as WorkerToHostMessage;
|
||||
switch (typed.type) {
|
||||
case "output": {
|
||||
buffered.push(typed);
|
||||
bufferedChars += typed.item.type === "text" ? typed.item.text.length : typed.item.data.length;
|
||||
if (buffered.length >= FLUSH_ITEMS || bufferedChars >= FLUSH_CHARS) {
|
||||
exchange(takeBuffer()).then(reply, () => end(undefined));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "started":
|
||||
buffered.push(typed);
|
||||
break;
|
||||
// Sent at once: a host replaying a script waits for it to see that the script issued no further call.
|
||||
case "call":
|
||||
case "idle":
|
||||
exchange([...takeBuffer(), typed]).then(reply, () => end(undefined));
|
||||
break;
|
||||
default:
|
||||
end(typed);
|
||||
}
|
||||
};
|
||||
|
||||
const launch = inlineLauncher(options.wasm, budget);
|
||||
Promise.resolve(
|
||||
launch(data, {
|
||||
message: onMessage,
|
||||
failure: (error) => end({ type: "failure", error }),
|
||||
}),
|
||||
).then(
|
||||
(started) => {
|
||||
channel = started;
|
||||
if (ended) {
|
||||
void started.stop().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
for (const message of unsent.splice(0)) started.post(message);
|
||||
},
|
||||
(error: unknown) => end({ type: "failure", error: { kind: "sandbox", message: errorMessage(error) } }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function remoteLauncher(remote: () => CodemodeRemote, interruptBudget: number | undefined): VmLauncher {
|
||||
return (data, events) => {
|
||||
let stopped = false;
|
||||
let settled = false;
|
||||
const waiting = new Map<number, (reply: CodemodeRemoteReply) => void>();
|
||||
|
||||
const exchange: CodemodeRemoteExchange = (messages) =>
|
||||
new Promise<CodemodeRemoteReply>((resolve) => {
|
||||
if (stopped || !Array.isArray(messages)) {
|
||||
resolve({ type: "stop" });
|
||||
return;
|
||||
}
|
||||
// Register the waiter first: the host may answer a call synchronously, for example when the
|
||||
// tool throws before returning a promise.
|
||||
const call = messages.find((message) => isWorkerToHostMessage(message) && message.type === "call");
|
||||
if (call?.type === "call") waiting.set(call.id, resolve);
|
||||
else resolve(null);
|
||||
for (const message of messages) {
|
||||
if (typeof message === "object" && message !== null && message.type === "failure") {
|
||||
settled = true;
|
||||
events.failure(message.error);
|
||||
continue;
|
||||
}
|
||||
// Malformed messages still go to the host, which fails the execution as a broken bridge.
|
||||
events.message(message);
|
||||
if (isWorkerToHostMessage(message) && (message.type === "done" || message.type === "crash"))
|
||||
settled = true;
|
||||
}
|
||||
});
|
||||
|
||||
let target: CodemodeRemote;
|
||||
try {
|
||||
target = remote();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to reach the remote sandbox: ${errorMessage(error)}`);
|
||||
}
|
||||
const request: CodemodeRemoteRequest = interruptBudget === undefined ? { data } : { data, interruptBudget };
|
||||
Promise.resolve()
|
||||
.then(() => target.execute(request, exchange))
|
||||
.then(
|
||||
() => {
|
||||
if (!settled && !stopped) {
|
||||
events.failure({ kind: "sandbox", message: "Remote sandbox returned before the script settled" });
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!stopped)
|
||||
events.failure({ kind: "sandbox", message: `Remote sandbox failed: ${errorMessage(error)}` });
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
post: (message) => {
|
||||
const resolve = waiting.get(message.id);
|
||||
if (!resolve) return;
|
||||
waiting.delete(message.id);
|
||||
resolve(message);
|
||||
},
|
||||
stop: async () => {
|
||||
stopped = true;
|
||||
for (const resolve of waiting.values()) resolve({ type: "stop" });
|
||||
waiting.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs each execution's VM on a remote, usually a Cloudflare Durable Object deployed with
|
||||
* `CodemodeSandboxDurableObject`, while tools run here. Same API and results as
|
||||
* `CodemodeSandbox`, so it can replace it. Differences: a script that computes without calling a
|
||||
* tool is limited by the remote's interrupt budget rather than `timeoutMs`, and after a deadline or
|
||||
* abort the remote VM keeps computing until it next calls a tool or uses up that budget.
|
||||
*/
|
||||
export class RemoteCodemodeSandbox extends CodemodeSandboxBase {
|
||||
constructor(options: RemoteCodemodeSandboxOptions) {
|
||||
super(options, remoteLauncher(options.remote, options.interruptBudget));
|
||||
}
|
||||
}
|
||||
341
third_party/pi/packages/codemode/src/runtime/vm.ts
vendored
Normal file
341
third_party/pi/packages/codemode/src/runtime/vm.ts
vendored
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
39
third_party/pi/packages/codemode/src/runtime/worker.ts
vendored
Normal file
39
third_party/pi/packages/codemode/src/runtime/worker.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Worker thread entry. One worker runs one script inside a fresh QuickJS VM
|
||||
* (a separate wasm instance), relays tool calls and output to the host, and
|
||||
* reports the result. The host terminates the worker when the script settles,
|
||||
* times out, or is aborted; the worker exists so that a spinning script never
|
||||
* blocks the host thread.
|
||||
*
|
||||
* Importing this module starts the worker. Hosts that bundle their code (for
|
||||
* example a Bun compiled executable) add a file that imports
|
||||
* `@earendil-works/pi-codemode/worker` as a separate entrypoint and pass its URL
|
||||
* or embedded-module string specifier as `workerUrl`.
|
||||
*/
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
import { isHostToWorkerMessage, type WorkerData, type WorkerToHostMessage } from "./protocol.ts";
|
||||
import { startVm } from "./vm.ts";
|
||||
|
||||
function post(message: WorkerToHostMessage): void {
|
||||
parentPort?.postMessage(message);
|
||||
}
|
||||
|
||||
async function main(data: WorkerData): Promise<void> {
|
||||
const interrupt = new Int32Array(data.interrupt);
|
||||
// The VM lives until the host terminates the worker, so the session is never disposed.
|
||||
const session = await startVm({
|
||||
data,
|
||||
wasm: data.wasm,
|
||||
post,
|
||||
shouldInterrupt: () => Atomics.load(interrupt, 0) !== 0,
|
||||
});
|
||||
parentPort?.on("message", (message: unknown) => {
|
||||
if (isHostToWorkerMessage(message)) session.deliver(message);
|
||||
});
|
||||
}
|
||||
|
||||
if (parentPort) {
|
||||
main(workerData as WorkerData).catch((error: unknown) => {
|
||||
post({ type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user