Skip to content

feat: add QuickJS executor as a sibling backend to isolated-vm#11

Merged
robinbraemer merged 7 commits into
mainfrom
feat/quickjs-executor
May 23, 2026
Merged

feat: add QuickJS executor as a sibling backend to isolated-vm#11
robinbraemer merged 7 commits into
mainfrom
feat/quickjs-executor

Conversation

@robinbraemer
Copy link
Copy Markdown
Member

Summary

Adds a QuickJSExecutor (pure-WASM quickjs-emscripten) as an optional sibling backend to the existing IsolatedVMExecutor. createExecutor in auto.ts now picks by runtime:

  • Bun → QuickJS first (isolated-vm cannot dlopen under JSC — v8::* symbols are unavailable)
  • Node → isolated-vm first, QuickJS as fallback if not installed

This unblocks consumers running under Bun, Cloudflare Workers, or any environment where isolated-vm's V8 native binding cannot load. isolated-vm remains the recommended production backend on Node.

What's in scope

  • src/executor/quickjs.tsQuickJSExecutor implements Executor, matching the ExecuteResult and ExecuteStats shape of IsolatedVMExecutor (verified by a focused parity test)
  • src/executor/auto.ts — runtime-branch selector
  • src/index.ts — export QuickJSExecutor
  • package.jsonquickjs-emscripten ^0.32.0 as optional peer + devDependency
  • tsup.config.ts — externalize quickjs-emscripten
  • test/quickjs-executor.test.ts — 16 tests mirroring the isolated-vm suite + an explicit ExecuteStats shape-parity test

Known upstream bugs (not in scope to fix here)

quickjs-emscripten@0.32.0 release-asyncify has two unpatched regressions that surface when guest code does multiple sequential await hostFn() calls. These reproduce identically on Node 24.13.1 and Bun 1.3.9 — same QuickJS C-side assertion at quickjs.c:6009 (p->ref_count == 0). Not engine-specific.

The QuickJSExecutor docstring documents this and the implementation includes workarounds for two related construction-ordering bugs (eval-before-asyncify-registration; IIFE result handle GC-anchoring).

Guest-code constraint for callers: prefer Promise.all over sequential await chains. Applies on every runtime, not just Bun.

Stats parity

Locked by a test in test/quickjs-executor.test.ts:

ExecuteStats shape parity (QuickJS vs IsolatedVM) > both executors produce
ExecuteStats with the same keys

Asserts Object.keys(stats).toSorted() identical between both executors and every value is a finite number. V8-specific counters (executable bytes, etc.) are best-effort or 0 — shape preserved, not value parity.

Test plan

  • pnpm lint (oxlint + tsc): clean
  • pnpm test: 96 passing across 7 files (Node 24.13.1)
  • Empirically verified the upstream bugs reproduce on both Node and Bun with the same assertion before reframing the docstring

Introduces `QuickJSExecutor` using `quickjs-emscripten` (pure WASM, no
native compile). Auto-selection in `createExecutor`:

- On Bun, QuickJS is tried first because isolated-vm cannot dlopen under
  JavaScriptCore (`v8::ValueSerializer::Delegate::IsHostObject` is not
  exported).
- On Node, isolated-vm is preferred for V8 JIT speed; QuickJS is a
  graceful fallback when isolated-vm isn't installed.

Both executors produce the same `ExecuteResult` and `ExecuteStats` shape
so callers can swap backends. QuickJS-specific stats fall back to 0
where no V8 analog exists (executableBytes, peakMallocedBytes, etc.) —
the dedicated parity test in `quickjs-executor.test.ts` locks the
schema.

Mirror test suite (16 tests) covers the same behaviours as
`isolated-vm-executor.test.ts` — globals, namespaces, sequential
awaits, Promise.all, memory + CPU + wall-clock timeouts, isolation,
no-Node-APIs, error surfacing.

Workarounds for release-asyncify quirks documented in `quickjs.ts`:
- Host functions registered before any `evalCode`/`evalCodeAsync` to
  avoid asyncify bookkeeping corruption that crashes the second
  sequential `await` in user code.
- No-op console + global injection built via handle API only
  (`newObject`/`newFunction`/`newNumber`/`newString`), no eval.
- User code wrapped in `JSON.stringify` envelope so the result handle
  read back from QuickJS is a primitive string — works around an
  asyncify GC race where the IIFE return value is freed under us.

`quickjs-emscripten` added as an optional peer dependency alongside
`isolated-vm`.

Known limitation: on Bun, single `await hostFn()` works but multiple
sequential awaits currently surface WASM "Out of bounds memory access"
— upstream interaction between Bun's WASM runtime and quickjs-
emscripten asyncify build. Documented in `QuickJSExecutor`'s JSDoc.
Empirically reproduced the sequential-await crash on Node 24.13.1 AND
Bun 1.3.9 — same QuickJS assertion at quickjs.c:6009 (free_zero_refcount,
p->ref_count == 0). The earlier docstring attributed this to Bun's JSC
WASM runtime; that was wrong.

Reframe the QuickJSExecutor docstring:
- Lead with "not a production backend" — exists so the package loads on
  runtimes where isolated-vm cannot dlopen (Bun, CF Workers, browser).
  Production callers on Node use IsolatedVMExecutor.
- Attribute crashes to upstream quickjs-emscripten@0.32.0 release-asyncify
  regressions: #258 (multiple sequential awaits → assertion failure +
  WASM trap) and #261 (newRuntime dispose-order, asyncified host fns).
  Both reproduce on Node and Bun identically.
- Document guest-code constraint: avoid sequential await on host
  functions; use Promise.all instead. Applies on every runtime.

Update auto.ts to match: clarify isolated-vm is the recommended Node
backend and QuickJS is a compatibility fallback, not a production choice.

No behaviour change. Tests + lint clean.
Switch isBun() from `typeof globalThis.Bun` (alternative per Bun docs)
to `process.versions.bun` (recommended per
https://bun.com/docs/guides/util/detect-bun).

The `process` access goes through a globalThis cast so we don't have to
add @types/node as a devDependency just for one symbol — same approach
the file already used for the Bun global. The `proc?.versions?.bun`
optional chain also keeps the function safe in runtimes where `process`
is undefined (browser, Cloudflare Workers).

No behaviour change on Bun, Node, or any host with a Bun-prefixed
process.versions.

Tests + lint clean.
- Bump @robinbraemer/codemode 0.1.6 → 0.2.0. New optional peer + new
  public class (`QuickJSExecutor`, `createExecutor`) warrant a minor.
- README: add Executors comparison table, install instructions for both
  backends, and a "QuickJSExecutor caveats" section covering
  not-for-production framing, the sequential-await constraint, the
  Date/Map/BigInt structured-clone divergence, and the wall-clock CPU
  timeout. Previously these were only visible in source docstrings.
- quickjs.ts docstring: add "Semantic divergences from IsolatedVMExecutor"
  section calling out return-value type fidelity loss and the
  wall-clock-based CPU timeout approximation.
- quickjs.ts disposal: drop the redundant `runtime.dispose()` call.
  `context.dispose()` already owns the runtime lifetime via
  ownedLifetimes; calling runtime.dispose() separately throws
  QuickJSUseAfterFree which the catch silently swallowed and obscured
  real disposal errors.
- Add a parity test in `quickjs-executor.test.ts` that asserts the
  Date divergence explicitly: isolated-vm returns a Date instance,
  QuickJSExecutor returns its ISO-8601 string. Locks the known
  divergence so any future change (e.g. upstream fix lets us drop
  the JSON envelope) breaks loudly.

Tests: 97 passing (was 96, +1 divergence-lock test). Lint clean.
The IsolatedVMExecutor and QuickJSExecutor test files were near-mirrors —
parity between the two backends was intent-based and required manual
synchronisation whenever a contract test was added or changed.

Extract the backend-agnostic assertions into a single `executorContract`
helper in `test/executor-contract.ts`. The two backend test files now
just call it with their respective factory, so any new contract test
automatically runs against both runtimes:

  executorContract("IsolatedVMExecutor", (opts) => new IsolatedVMExecutor(opts));
  executorContract("QuickJSExecutor",   (opts) => new QuickJSExecutor(opts),
    { memoryStress: { memoryMB: 4, iterations: 1_000_000 } });

The only knob the contract exposes is `memoryStress` — QuickJS OOMs at a
lower limit with a tighter loop than V8, and the only way to keep test
intent identical is to parametrise the absolute thresholds per backend
rather than branch inside the contract.

Cross-backend tests (`ExecuteStats shape parity`, structured-clone
divergence) stay in `quickjs-executor.test.ts`: they compare both
executors side-by-side, so they're not part of the contract.

Same 97 tests, same describe titles, no behaviour change.

Note: template-string indentation for code passed to `codemode.search` /
`codemode.execute` intentionally stays at the pre-refactor 6-space
prefix instead of the 8 you'd expect from being nested inside a
function. Quickjs-emscripten@0.32.0 release-asyncify deadlocks on
certain leading-whitespace patterns in user code (related to the GC
anchoring issue documented in src/executor/quickjs.ts). Until that's
fixed upstream, preserving the working layout is the safe choice.
Bring in pending Renovate dep-update PRs so a single CI run covers them
all instead of one CI run per merged PR.

Included:
- #3 vitest ^3.0.5 → ^4.0.0 (now 4.1.7) — test runner major bump,
  97/97 tests still green
- #6 all non-major dep bumps (absorbed via pnpm install lockfile regen):
  convex 1.51→1.66, hono 4.12.5→4.12.22, isolated-vm 6.0.2→6.1.2, tsx
  4.21.0→4.22.3, zod 4.3.6→4.4.3, and transitive bumps
- #8 jdx/mise-action@v2 → @v4 in ci.yml + publish.yml
- #9 typescript ^5.7.3 → ^6.0.0 (now 6.0.x) — tsc clean

Dropped:
- #10 pnpm 10→11. mise's aqua registry for pnpm 11 doesn't expose a
  matching darwin-arm64 asset name yet (looks for `pnpm-macos-arm64`,
  upstream ships `pnpm-darwin-arm64.tar.gz`). Renovate will reopen
  whenever the registry catches up.

oxlint + tsc clean, vitest 97/97.
tsup's DTS bundler implicitly sets baseUrl during type emission, which
TS 6 flags as deprecated (error TS5101: 'baseUrl' is deprecated and will
stop functioning in TypeScript 7.0). Adding the documented escape hatch
keeps the build green; revisit when tsup drops the implicit baseUrl or
when we move to TS 7.

Local pnpm build + lint + test green (97/97).
@robinbraemer robinbraemer merged commit 4f4058a into main May 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant