feat(coding-agent): run code mode on dquickts instead of quickjs-wasi

Code mode scripts now run on @earendil-works/dquickts 0.2.0 (Bellard's
QuickJS compiled to WebAssembly) instead of quickjs-wasi (QuickJS-NG):
about 1.3 to 1.9 times faster, with a larger wasm (about 850 KB).

- Deep recursion fails with InternalError: stack overflow instead of
  RangeError; the inline and remote sandboxes allow about 950 calls.
- Deterministic runs: the fingerprint names the engine, and Math.random()
  gives other values for a seed.
- Tests: no tool runs after an interrupted async function, and a loop of
  interrupted async functions ends with a timeout.
This commit is contained in:
Armin Ronacher 2026-10-31 15:45:59 +01:00
parent f09d01b85e
commit 748cd5ac57
20 changed files with 85 additions and 55 deletions

19
package-lock.json generated
View file

@ -757,6 +757,15 @@
"resolved": "packages/chord",
"link": true
},
"node_modules/@earendil-works/dquickts": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@earendil-works/dquickts/-/dquickts-0.2.0.tgz",
"integrity": "sha512-Wlg2tN0xHgWNf3yS6KWenzlLY/EQ0ifS0Qj0wbYi4/7cT+X5saQkVFNyPzRHYvo0kuF4e6hWQJsyvG+0pb27lQ==",
"license": "MIT",
"engines": {
"node": ">=22.18.0"
}
},
"node_modules/@earendil-works/gondolin": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz",
@ -4720,12 +4729,6 @@
"once": "^1.3.1"
}
},
"node_modules/quickjs-wasi": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz",
"integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==",
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@ -5830,7 +5833,7 @@
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"quickjs-wasi": "3.6.2"
"@earendil-works/dquickts": "0.2.0"
},
"devDependencies": {
"shx": "0.3.4",
@ -5845,6 +5848,7 @@
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"@earendil-works/dquickts": "0.2.0",
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@earendil-works/pi-codemode": "1.4.0",
@ -5862,7 +5866,6 @@
"jiti": "2.7.0",
"minimatch": "10.2.6",
"proper-lockfile": "4.1.2",
"quickjs-wasi": "3.6.2",
"semver": "7.8.5",
"typebox": "1.3.27",
"undici": "8.12.3",

View file

@ -2,6 +2,12 @@
## [Unreleased]
### Breaking Changes
- Scripts now run on dquickts (`@earendil-works/dquickts`, Bellard's QuickJS compiled to WebAssembly) instead of `quickjs-wasi` (QuickJS-NG). Hosts that pass their own `wasm` must pass `@earendil-works/dquickts/quickjs.wasm`; `loadQuickJSWasm()`, `cloudflareQuickJSWasm`, and `createInlineCodemodeSandbox()` load it already. Scripts run about 1.3 to 1.9 times faster, and the Wrangler bundle grows from about 640 KB to 850 KB
- Deep recursion in a script now throws a catchable `InternalError: stack overflow` instead of `RangeError: Maximum call stack size exceeded`. The inline and remote sandboxes allow about 950 calls instead of 1,000
- Deterministic runs: the engine is part of the VM fingerprint, so a run started before this version reports a different fingerprint and is not replayed on it. `Math.random()` gives other values for the same seed
## [1.4.0] - 2026-10-21
## [1.3.0] - 2026-10-15

View file

@ -105,7 +105,7 @@ Supported fields are `max_output_tokens`, a token budget for the output, and `ti
## Bundled Node and Bun hosts
By default `CodemodeSandbox` loads `quickjs-wasi/quickjs.wasm` from the installed package and starts the worker file that sits next to this package's module. Neither exists on disk when the host is bundled, so pass both:
By default `CodemodeSandbox` loads `@earendil-works/dquickts/quickjs.wasm` from the installed package and starts the worker file that sits next to this package's module. Neither exists on disk when the host is bundled, so pass both:
```ts
import { CodemodeSandbox, loadQuickJSWasm } from "@earendil-works/pi-codemode";
@ -170,7 +170,7 @@ export default {
}
```
Install `@earendil-works/pi-codemode` in the sandbox Worker's project and run `npx wrangler deploy`. Wrangler bundles QuickJS (about 640 KB) as a compiled WebAssembly module. The object has no routes and keeps no state, and it never uses its storage.
Install `@earendil-works/pi-codemode` in the sandbox Worker's project and run `npx wrangler deploy`. Wrangler bundles QuickJS (about 850 KB) as a compiled WebAssembly module. The object has no routes and keeps no state, and it never uses its storage.
### Using it from the agent's Worker
@ -369,6 +369,6 @@ The worker keeps script execution off the host thread: QuickJS runs synchronousl
## Runtime notes
- Node and Bun support memory limits, interrupts, and a catchable `RangeError` for deep recursion (QuickJS's stack guard is enabled; without it the wasm stack overflows and traps). Bun may delay worker exit during non-interruptible built-ins; results do not wait for exit.
- Node and Bun support memory limits, interrupts, and a catchable `InternalError: stack overflow` for deep recursion (QuickJS's stack guard is enabled; without it the wasm stack overflows and traps). Bun may delay worker exit during non-interruptible built-ins; results do not wait for exit.
- Stack traces use QuickJS frames (`at f (codemode.js:2:31)`), prefixed with `Name: message` like V8. The wrapper prefix shares line 1 with the script, so line numbers are exact; column numbers on line 1 are shifted.
- QuickJS is an interpreter. Glue code and filtering tool results are fast enough; heavy computation is slower than in V8.

View file

@ -67,7 +67,7 @@
"node": ">=22.19.0"
},
"dependencies": {
"quickjs-wasi": "3.6.2"
"@earendil-works/dquickts": "0.2.0"
},
"devDependencies": {
"shx": "0.3.4",

View file

@ -10,7 +10,7 @@ declare module "cloudflare:workers" {
}
}
// A pattern, not the exact specifier: coding-agent declares "quickjs-wasi/quickjs.wasm" as a path
// A pattern, not the exact specifier: coding-agent declares "@earendil-works/dquickts/quickjs.wasm" as a path
// for Bun, and an exact declaration takes precedence where both are visible. cloudflare.ts casts the
// import to the compiled module Wrangler actually produces.
declare module "*/quickjs.wasm" {

View file

@ -13,7 +13,7 @@
* that computes blocks the caller until it awaits a tool or uses up its interrupt budget.
*/
import { DurableObject } from "cloudflare:workers";
import quickjsWasmImport from "quickjs-wasi/quickjs.wasm";
import quickjsWasmImport from "@earendil-works/dquickts/quickjs.wasm";
import {
DEFAULT_INTERRUPT_BUDGET,
InlineCodemodeSandbox,
@ -31,7 +31,7 @@ const quickjsWasm = quickjsWasmImport as unknown as CodemodeWasmModule;
/** Default VM memory limit of {@link CodemodeSandboxDurableObject}. Isolates have 128 MB in total. */
export const DEFAULT_REMOTE_MEMORY_LIMIT_BYTES = 32 * 1024 * 1024;
/** `quickjs-wasi/quickjs.wasm`, compiled by Wrangler. */
/** `@earendil-works/dquickts/quickjs.wasm`, compiled by Wrangler. */
export const cloudflareQuickJSWasm: CodemodeWasmModule = quickjsWasm;
/** `InlineCodemodeSandbox` with the QuickJS module Wrangler bundled. */

View file

@ -38,12 +38,12 @@ 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. */
/** Allows recursion about 950 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`
* Compiled `@earendil-works/dquickts/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>;

View file

@ -28,7 +28,7 @@ export interface VmStartData {
}
export interface WorkerData extends VmStartData {
/** Compiled `quickjs-wasi` module. Structured clone shares the compiled code with the worker. */
/** Compiled `@earendil-works/dquickts` 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

View file

@ -35,7 +35,7 @@
* 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 { Intrinsics, JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "@earendil-works/dquickts";
import type { CodemodeWasmModule } from "../wasm.ts";
import { PRELUDE_SOURCE } from "./prelude-source.ts";
import type { HostToWorkerMessage, VmStartData, WorkerToHostMessage } from "./protocol.ts";
@ -50,7 +50,8 @@ export interface StartVmOptions {
data: VmStartData;
wasm: CodemodeWasmModule;
/**
* QuickJS's guard on its own stack, which turns deep recursion into a catchable `RangeError`.
* QuickJS's guard on its own stack, which turns deep recursion into a catchable
* `InternalError: stack overflow`.
* 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.
@ -171,7 +172,7 @@ export async function startVm(options: StartVmOptions): Promise<VmSession> {
wasm: options.wasm,
memoryLimit: data.memoryLimitBytes,
// Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a
// catchable RangeError.
// catchable InternalError.
maxStackSize,
interruptHandler: options.shouldInterrupt,
wasi: clock ? deterministicWasi(clock) : discardOutput,
@ -235,15 +236,13 @@ export async function startVm(options: StartVmOptions): Promise<VmSession> {
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();
// `executePendingJobs()` runs the queue to empty without a chance to interrupt, so jobs run one at a time.
/** 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");
while (vm.isJobPending()) {
// Script errors, interruptions included, reject their promise; this only throws if the job itself fails.
vm.executePendingJob();
if (++jobs % JOBS_PER_POLL === 0 && options.shouldInterrupt()) throw new Error("Execution interrupted");
}
vm.callFunction(stalled, api).dispose();
@ -316,7 +315,7 @@ export async function startVm(options: StartVmOptions): Promise<VmSession> {
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}`,
fingerprint: `dquickts ${versions.dquickts}, quickjs ${versions.quickjs}, prelude ${PRELUDE_HASH}, deterministic ${DETERMINISTIC_VERSION}, stack ${maxStackSize}, heap ${data.memoryLimitBytes ?? "none"}, budget ${budget}`,
});
}

View file

@ -134,8 +134,8 @@ export interface CodemodeSandboxBaseOptions {
/** Options of `CodemodeSandbox`, which runs each VM in a Node or Bun worker thread. */
export interface CodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
/**
* Compiled `quickjs-wasi/quickjs.wasm`, usually from {@link loadQuickJSWasm}. Default:
* `loadQuickJSWasm()`, the file in the installed `quickjs-wasi` package. Pass it when that file
* Compiled `@earendil-works/dquickts/quickjs.wasm`, usually from {@link loadQuickJSWasm}. Default:
* `loadQuickJSWasm()`, the file in the installed `@earendil-works/dquickts` package. Pass it when that file
* is not on disk, for example in a Bun compiled executable.
*/
wasm?: CodemodeWasmModule | Promise<CodemodeWasmModule>;

View file

@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
/**
* A compiled `WebAssembly.Module` of `quickjs-wasi/quickjs.wasm`. Typed opaquely because the Node
* A compiled `WebAssembly.Module` of `@earendil-works/dquickts/quickjs.wasm`. Typed opaquely because the Node
* type definitions do not declare the WebAssembly globals (they live in the DOM lib).
*/
export type CodemodeWasmModule = object;
@ -15,11 +15,11 @@ const modules = new Map<string, Promise<CodemodeWasmModule>>();
/**
* Read and compile the QuickJS wasm once per path. `path` defaults to the file in the installed
* `quickjs-wasi` package; pass it when that file lives elsewhere, for example embedded in a Bun
* `@earendil-works/dquickts` package; pass it when that file lives elsewhere, for example embedded in a Bun
* compiled executable. A failed load is retried on the next call.
*/
export function loadQuickJSWasm(path?: string): Promise<CodemodeWasmModule> {
const resolved = path ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
const resolved = path ?? createRequire(import.meta.url).resolve("@earendil-works/dquickts/quickjs.wasm");
let module = modules.get(resolved);
if (!module) {
const { WebAssembly } = globalThis as unknown as { WebAssembly: WebAssemblyGlobal };

View file

@ -194,7 +194,7 @@ for (const [backend, create] of Object.entries(BACKENDS)) {
]);
const fingerprint = recorded.fingerprint()!;
expect(fingerprint).toMatch(
/^quickjs-wasi \S+, quickjs \S+, prelude [0-9a-f]{8}, deterministic \d+, stack \d+, heap 67108864, budget \S+$/,
/^dquickts \S+, quickjs \S+, prelude [0-9a-f]{8}, deterministic \d+, stack \d+, heap 67108864, budget \S+$/,
);
if (backend === "remote") expect(fingerprint).toContain(`budget ${BUDGET}`);
else expect(fingerprint).toContain("budget none");
@ -404,7 +404,7 @@ async function waitFor(check: () => boolean): Promise<void> {
}
/** The first two `Math.random()` values with seed 0. */
const PINNED_SEED_ZERO = [0.6711372530266764, 0.7258461452833667];
const PINNED_SEED_ZERO = [0.28083505005035936, 0.6711372530266764];
/** The first `Math.random()` with seed 1. */
const PINNED_FIRST_RANDOM = 0.34227450605335275;
const PINNED_FIRST_RANDOM = 0.5616701001007189;

View file

@ -584,6 +584,24 @@ describe.each(Object.keys(BACKENDS))("%s backend", (name) => {
expect(result).toMatchObject({ ok: false, error: { kind: "timeout" } });
});
it("calls no tool after an interrupted async function", async () => {
// The interrupted function's rejection handler and the code after its call must not run.
let calls = 0;
const sandbox = createSandbox([{ name: "side", execute: () => ++calls }]);
const result = await sandbox.execute(
"const p = (async () => { while (true) {} })(); p.catch(() => tools.side()); tools.side(); await p",
{ timeoutMs: 200 },
);
expect(result).toMatchObject({ ok: false, error: { kind: "timeout" } });
expect(calls).toBe(0);
});
it("terminates a loop of interrupted async functions on timeout", async () => {
const sandbox = createSandbox();
const result = await sandbox.execute("while (true) (async () => { while (true) {} })()", { timeoutMs: 200 });
expect(result).toMatchObject({ ok: false, error: { kind: "timeout" } });
});
it("aborts via signal and cancels in-flight calls", async () => {
const controller = new AbortController();
let toolSignal: AbortSignal | undefined;
@ -671,14 +689,14 @@ describe.each(Object.keys(BACKENDS))("%s backend", (name) => {
expect(results.map((result) => (result.ok ? result.value : result.error))).toEqual(["a", "b", "undefined"]);
});
it("turns deep recursion into a catchable RangeError", async () => {
it("turns deep recursion into a catchable InternalError", async () => {
const sandbox = createSandbox();
const result = await sandbox.execute(`
let depth = 0;
function dive() { depth++; dive(); }
try { dive(); } catch (error) { return [error.name, depth > 1000]; }
try { dive(); } catch (error) { return [error.name, depth > 900]; }
`);
expect(result).toMatchObject({ ok: true, value: ["RangeError", true] });
expect(result).toMatchObject({ ok: true, value: ["InternalError", true] });
});
});

View file

@ -12,6 +12,7 @@
- `pi update` for installs from the pi.dev installer no longer passes `--min-release-age=0` to `npm ci`. The release lockfile pins every package, so the flag had no effect, and security scanners that block it stopped the update ([#11262](https://github.com/earendil-works/pi/issues/11262))
- `/model` and `/scoped-models` now show a model's name next to its ID when the name adds words the ID lacks, such as a renamed `models.json` override ([#5916](https://github.com/earendil-works/pi/issues/5916), [#11347](https://github.com/earendil-works/pi/pull/11347))
- Code mode scripts now run on dquickts (`@earendil-works/dquickts`) instead of `quickjs-wasi`, about 1.3 to 1.9 times faster. Deep recursion in a script now fails with `InternalError: stack overflow` instead of `RangeError`
### Fixed

View file

@ -385,6 +385,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@earendil-works/dquickts": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@earendil-works/dquickts/-/dquickts-0.2.0.tgz",
"integrity": "sha512-Wlg2tN0xHgWNf3yS6KWenzlLY/EQ0ifS0Qj0wbYi4/7cT+X5saQkVFNyPzRHYvo0kuF4e6hWQJsyvG+0pb27lQ==",
"license": "MIT",
"engines": {
"node": ">=22.18.0"
}
},
"node_modules/@earendil-works/pi-agent-core": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz",
@ -485,7 +494,7 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz",
"license": "MIT",
"dependencies": {
"quickjs-wasi": "3.6.2"
"@earendil-works/dquickts": "0.2.0"
},
"engines": {
"node": ">=22.19.0"
@ -496,6 +505,7 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.4.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/dquickts": "0.2.0",
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@earendil-works/pi-codemode": "1.4.0",
@ -513,7 +523,6 @@
"jiti": "2.7.0",
"minimatch": "10.2.6",
"proper-lockfile": "4.1.2",
"quickjs-wasi": "3.6.2",
"semver": "7.8.5",
"typebox": "1.3.27",
"undici": "8.12.3",
@ -1326,12 +1335,6 @@
"node": ">= 20"
}
},
"node_modules/quickjs-wasi": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz",
"integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==",
"license": "MIT"
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",

View file

@ -41,6 +41,7 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/dquickts": "0.2.0",
"@earendil-works/pi-agent-core": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@earendil-works/pi-codemode": "1.4.0",
@ -58,7 +59,6 @@
"jiti": "2.7.0",
"minimatch": "10.2.6",
"proper-lockfile": "4.1.2",
"quickjs-wasi": "3.6.2",
"semver": "7.8.5",
"typebox": "1.3.27",
"undici": "8.12.3",

View file

@ -1,5 +1,5 @@
// Imported by runtime-setup.ts. Bun's default loader for .wasm evaluates to the file's path.
declare module "quickjs-wasi/quickjs.wasm" {
declare module "@earendil-works/dquickts/quickjs.wasm" {
const path: string;
export default path;
}

View file

@ -1,8 +1,8 @@
// Bun loads .wasm imports as files: embedded in compiled executables, evaluating to a readable path.
import quickjsWasmPath from "@earendil-works/dquickts/quickjs.wasm";
import { bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider";
import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth";
import { setBedrockProviderModule } from "@earendil-works/pi-ai/compat";
// Bun loads .wasm imports as files: embedded in compiled executables, evaluating to a readable path.
import quickjsWasmPath from "quickjs-wasi/quickjs.wasm";
import { APP_NAME, setEmbeddedQuickJSWasmPath } from "../config.ts";
process.title = APP_NAME;

View file

@ -488,11 +488,11 @@ export function setEmbeddedQuickJSWasmPath(path: string): void {
}
/**
* Get path to `quickjs-wasi/quickjs.wasm`, the VM that runs codemode scripts. Resolved once so the
* Get path to `@earendil-works/dquickts/quickjs.wasm`, the VM that runs codemode scripts. Resolved once so the
* compiled module cached per path keeps working after an update removes this install (#10439).
*/
export function getQuickJSWasmPath(): string {
quickJSWasmPath ??= createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
quickJSWasmPath ??= createRequire(import.meta.url).resolve("@earendil-works/dquickts/quickjs.wasm");
return quickJSWasmPath;
}

View file

@ -2,8 +2,8 @@ import { expect, test, vi } from "vitest";
import { createCodemodeExtension } from "../src/codemode/codemode.ts";
// The extension runs scripts in the sandbox the host passes, so loading it must not load the WASM VM (#11254).
vi.mock("quickjs-wasi", () => {
throw new Error("the Code Mode extension loaded quickjs-wasi");
vi.mock("@earendil-works/dquickts", () => {
throw new Error("the Code Mode extension loaded @earendil-works/dquickts");
});
test("the Code Mode extension loads without pi-codemode's WASM runtime", () => {