- Status: Accepted; extended in the same release (see Status note)
- Date: 2026-05-03
- Supersedes: —
- Superseded by: —
This ADR was drafted assuming Phase 3 (the gc-jsrt foundation) would
ship ahead of Phases 4–8. The release that landed this ADR also landed
those follow-on phases: gc-suggest dispatches all three
js_runtime.kind variants (post_process, script_function,
custom), the kill switch defaults to on, ghost-complete doctor and
status --json surface per-runtime diagnostics, and a coverage
regression gate is wired in CI. The Decision and Consequences sections
below describe the snapshot the ADR was written against; treat the
"Phase 4 will…" / "Phase 4 follow-up" wording as historical scoping,
not as a description of what the merged tree does. See
docs/JS_RUNTIME.md for the current behaviour.
180 of the 709 embedded specs carry requires_js: true generators;
aws alone ships ~1843 of them. ADR 0005 closed the demo gap with
native Rust providers for make, npm run, and cargo run -p, but
the rest of the corpus remains stuck behind the JS gate.
Phase 2 (UX-9, commit 36b83ca) preserved the structured
js_runtime metadata at spec-load time without yet executing it.
Phase 3 — this ADR — lands a bounded JavaScript evaluator that
later phases will dispatch to.
The constraints are:
- Sandboxing. Spec JS runs unprivileged. No filesystem, no network, no spawning, no module loader, no Node-style globals.
- Wall-clock bounded. A runaway
while(true)must not freeze the proxy. - Memory bounded. A
let a=[]; while(true) a.push(...)must not bring the host down. - Binary size sensitive. The corpus already pushed the binary to 103 MB; we want headroom under the 110 MB ceiling.
- Latency sensitive. Suggestion ranking budgets sit around 50 ms end-to-end; we cannot afford a per-keystroke runtime spin-up.
Add a new workspace crate gc-jsrt that owns the JavaScript
evaluation surface. Other crates depend on it as a dispatch target;
the rquickjs dependency does not leak past this boundary.
Architecture:
- rquickjs 0.10 with
default-features = falseand only therust-allocfeature enabled. Noloader,dyn-load,futures,parallel,macro, orphf. The runtime exposes only standard ECMA-262 intrinsics (JSON,Math,Date, regex, …) plus the thrower stubs we install foreval/Function. - One sync
rquickjs::Runtimeper worker, lifetime-pinned to a dedicated OS thread. The Tokio side talks to the worker throughstd::sync::mpsc(cheap, blocking on the worker side) plus per-jobtokio::sync::oneshotreply channels.AsyncRuntimeis not used — our JS is short, synchronous post-processing logic; a sync runtime is the smaller primitive. - Fresh
Contextper job. The runtime is reused across jobs to keep the GC warm and avoid allocator churn, but each job opens a brand-new context so two unrelated specs cannot pollute each other's globals. - Sandbox configuration strips
require,process,Deno,Bun,setTimeout/setInterval/setImmediate,fetch/XMLHttpRequest/WebSocket,Buffer,Worker, etc., then shadowsevalandFunctionwith closures that throwdisabled in gc-jsrt. Defense in depth — none of these are reachable from the QuickJS intrinsics we enable, but listing them explicitly makes the contract auditable. - Wall-clock interrupt via
Runtime::set_interrupt_handler. A sharedAtomicI64carries a deadline relative to the worker's process-startInstant; the handler returnstrueoncenow()passes it. The handler is cleared back toi64::MAXafter each job through an RAII guard. - Hard caps. Memory limit 8 MiB, max stack 512 KiB, GC threshold
2 MiB. Output is normalized through
JSON.stringify(which throws cleanly on cycles) with a 256 KiB total-bytes cap and a per-suggestion 256-byte name / 1024-byte description cap, plus a 1024-suggestion array cap with truncation.
Phase 3 ships the foundation only — the gc-suggest engine does
not yet call into gc-jsrt. The [suggest.providers] js_runtime
config field is wired into the schema (default true); Phase 4 begins
honouring it for post_process generators.
- The corpus' three JS shapes (
post_process,script_function,custom) all reduce to the sameevaluate(program, input, deadline)primitive. Phase 4 and Phase 5 build dispatch on top of one stable surface. - The dependency boundary keeps rquickjs off
gc-suggest's compile graph in tests and benchmarks that don't need it. Runtime::set_interrupt_handlerplus the per-job context reset gives a much stronger isolation guarantee than embedding JS in the same address space without bounds.- The sync runtime is ~2x faster to spawn than
AsyncRuntimeand has a smaller dependency footprint.
- Bounded but not hard real-time. The interrupt handler fires on QuickJS' periodic checks; a long native operation (pathological regex, JSON parsing of huge strings) may overshoot the deadline. Memory and output caps are the second line of defence.
- Binary size. Phase 3 alone added no measurable size to the
binary because nothing links
gc-jsrtyet. Phase 4 will pull the dependency in; budget is tracked inbenchmarks/binary-size-baseline.txtagainst the 110 MB ceiling. - Per-job context allocation is a fixed cost. Microbenchmarks pending — Phase 4 will report numbers when the engine path is wired.
- Boa engine. Rust-native, no FFI, simpler build, but ~3x slower on script-heavy benchmarks and the API surface lacks the per-context isolation primitives we need.
- deno_core. Heavier dependency tree (V8), much larger binary impact, and brings Node-style APIs we'd then need to strip.
- Subprocess to a real Node runtime. Defeats the latency budget — process spawn alone exceeds 50 ms on macOS.
- rquickjs
AsyncRuntime. Useful when JS calls back into Rust futures; we don't, so the extra complexity (futures lock, async mutex) is unjustified.
- Phase 2 commit:
36b83ca—js_runtimeschema + converter. - Phase 3 implementation:
crates/gc-jsrt/. - Phase 4 follow-up:
gc-suggestdispatch forpost_processkind. - rquickjs upstream: https://github.com/delskayn/rquickjs.