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.

RuntimeMemoryCold startInstances/GB
V8/Node30MB50ms~30
QuickJS500KB<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 on
  • inference --- 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.

LimitValue
Base memory500KB
Execution contextup to 1MB
Max stack size256KB
#![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 context object
  • Call inference() for model APIs (via the bridge)
  • Return structured results via return

CANNOT:

  • Access the filesystem (fs is 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.

BoundaryMechanism
Timeouttokio::time::timeout aborts runaway execution (default 5s)
MemoryQuickJS enforces the per-instance memory limit natively
IsolationA 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

MetricTarget
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
  1. acquire --- take a permit + runtime from the pool
  2. create_context --- build a fresh full context
  3. inject_globals --- set context and inference
  4. execute --- run the code under the timeout (the only variable-duration step)
  5. collect --- deserialize the return value
  6. 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.