JavaScript Runtime (QuickJS)
Qadra executes sandboxed JavaScript inside an embedded QuickJS runtime. This is the engine that powers the Plugins system---extractors, validators, and evaluators are all lightweight QuickJS programs that transform data without calling models. Agents call models; plugins transform data; both rely on isolation, but only the runtime makes per-plugin isolation cheap enough to be practical.
Why QuickJS
The plugin pattern requires instant-on, isolated runtimes. Every plugin execution should get a fresh, sandboxed context with no shared state---and that has to be cheap, because the platform spins up thousands of them.
| Runtime | Memory | Cold start | Instances/GB |
|---|---|---|---|
| V8/Node | 30MB | 50ms | ~30 |
| QuickJS | 500KB | <1ms | ~2,000 |
The numbers are the whole argument. V8/Node gives you a fast, mature engine, but at ~30MB and ~50ms per instance you can only afford a few dozen per gigabyte. At that cost, the natural workaround is to stop isolating---batch many transformations into one big compound prompt or one shared context. That collapses the plugin model.
QuickJS is ~60x lighter and starts sub-millisecond, so isolation stays affordable. Plugins need isolation, not raw throughput, and that is exactly the trade QuickJS makes.
Rust integrates QuickJS via the rquickjs crate:
[dependencies]
rquickjs = { version = "0.4", features = ["full", "parallel"] }
Instance Pool
Runtimes are pre-created and pooled rather than spun up on demand. A semaphore bounds concurrency, each pooled runtime carries a memory limit, and executions are capped by a wall-clock timeout.
#![allow(unused)] fn main() { pub struct QuickJSPool { runtimes: Vec<Arc<Mutex<Runtime>>>, available: Semaphore, max_memory: usize, max_execution_time: Duration, } impl QuickJSPool { pub fn new(size: usize) -> Self { let runtimes = (0..size) .map(|_| { let rt = Runtime::new().expect("QuickJS runtime creation"); rt.set_memory_limit(1024 * 1024); // 1MB per instance Arc::new(Mutex::new(rt)) }) .collect(); Self { runtimes, available: Semaphore::new(size), max_memory: 1024 * 1024, max_execution_time: Duration::from_secs(5), } } } }
The pool size is configurable via QUICKJS_POOL_SIZE (default 100).
Execution Contract
Executing a plugin acquires a permit, takes an available runtime, builds a full context, injects globals, and runs the code under a timeout. The result is deserialized back into a Rust value.
#![allow(unused)] fn main() { pub async fn execute_atomic( pool: &QuickJSPool, code: &str, context: &ResolvedContext, ) -> Result<AtomicResult, RuntimeError> { let _permit = pool.available.acquire().await?; let runtime = pool.get_available(); let ctx = Context::full(&runtime)?; ctx.with(|ctx| { // Inject context as global let global = ctx.globals(); global.set("context", serialize_context(context)?)?; // Inject inference bridge (calls back to Rust) global.set("inference", create_inference_bridge())?; // Execute with timeout let result = tokio::time::timeout( pool.max_execution_time, async { ctx.eval::<Value, _>(code) } ).await??; // Parse result Ok(deserialize_result(&result)?) }) } }
Two things are always injected into the global scope before execution:
context--- the resolved input data the plugin operates oninference--- a bridge function that calls back into Rust for model APIs
Memory and Stack Limits
Each QuickJS instance runs under hard limits. There is no shared memory between instances---an allocation in one cannot affect another.
| Limit | Value |
|---|---|
| Base memory | 500KB |
| Execution context | up to 1MB |
| Max stack size | 256KB |
#![allow(unused)] fn main() { // Set memory limit runtime.set_memory_limit(1024 * 1024); // 1MB // Set max stack size runtime.set_max_stack_size(256 * 1024); // 256KB stack }
Plugins are even more tightly bounded than general atomics: the plugin execution model caps memory at 4MB with a 256KB stack, reflecting that data transformation is lighter work than model-driven reasoning.
What Sandboxed JavaScript Can and Cannot Do
The runtime is a sealed box. Code gets standard ECMAScript and the injected globals---nothing else.
CAN:
- Use standard ECMAScript (ES2020)
- Access the provided
contextobject - Call
inference()for model APIs (via the bridge) - Return structured results via
return
CANNOT:
- Access the filesystem (
fsis not available) - Make arbitrary network calls (only through the bridge)
- Import npm packages (no module system---dependencies must be pre-bundled into self-contained JavaScript via esbuild)
- Share state with any other instance
This is what makes plugins safe to run untrusted: there is no I/O surface a plugin can reach except the explicit bridge functions Rust hands it.
Bridge Functions
The only way sandboxed code reaches the outside world is through bridge functions injected by Rust. The primary one is inference(), which lets a plugin call the LLM provider:
#![allow(unused)] fn main() { fn create_inference_bridge() -> impl Fn(Value) -> Promise { |request: Value| async move { // Parse request from JS let req: InferenceRequest = serde_json::from_value(request)?; // Call Rust LLM provider let response = llm_provider.complete(req).await?; // Return to JS Ok(serde_json::to_value(response)?) } } }
From inside the plugin, the bridge looks like an ordinary async function:
async function execute(context) {
const response = await inference({
model: 'haiku',
prompt: buildPrompt(context),
maxTokens: 500,
});
return {
success: true,
output: response.content,
};
}
Every bridge is a deliberate hole in the sandbox. If a capability is not bridged, plugin code simply cannot do it.
Error Boundaries
QuickJS instances are fully isolated, so a misbehaving plugin is contained to its own execution.
| Boundary | Mechanism |
|---|---|
| Timeout | tokio::time::timeout aborts runaway execution (default 5s) |
| Memory | QuickJS enforces the per-instance memory limit natively |
| Isolation | A crash in one instance never affects others---each execution gets a fresh context |
#![allow(unused)] fn main() { // Timeout protection match tokio::time::timeout(Duration::from_secs(5), execution).await { Ok(result) => result, Err(_) => Err(RuntimeError::Timeout), } // Memory protection (built into QuickJS) runtime.set_memory_limit(max_memory); // Instance crash doesn't affect others // Each execution gets fresh context }
Plugin code should return errors as structured results rather than throwing; the execution wrapper also catches JavaScript exceptions and converts them into typed Rust errors.
Performance Targets
| Metric | Target |
|---|---|
| Instance spawn | <100us |
| Context creation | <500us |
| Cold start | <1ms |
| Memory per instance | <1MB |
| Max instances/GB | >1,000 |
The total overhead budget is <2ms per execution, excluding the plugin's own work.
Instance Lifecycle
A single execution flows through six steps, each with a tight time budget:
acquire() -> create_context() -> inject_globals() -> execute() -> collect() -> release()
| | | | | |
<10us <500us <100us varies <10us <10us
- acquire --- take a permit + runtime from the pool
- create_context --- build a fresh full context
- inject_globals --- set
contextandinference - execute --- run the code under the timeout (the only variable-duration step)
- collect --- deserialize the return value
- release --- return the permit to the pool
Monitoring
The pool exposes counters for observability:
#![allow(unused)] fn main() { pub struct PoolMetrics { pub total_executions: AtomicU64, pub active_instances: AtomicU32, pub timeouts: AtomicU64, pub memory_exceeded: AtomicU64, pub avg_execution_time: AtomicU64, } }
Watching timeouts and memory_exceeded surfaces plugins that are hitting their boundaries; active_instances against the pool size shows saturation.