Files
skills/docs/api-stack/api/api-sandbox-wasm-host-bridge/src/wasi-stdio/wasi-stdio-runner.md
2026-05-29 08:20:56 +01:00

5.2 KiB

wasi-stdio-runner

Purpose

Synchronous "one-envelope-per-_start" driver for WASI-stdio sandboxed plugins. Owns the WASM instantiation against a host-supplied WASI snapshot extended with stdin/stdout/stderr bindings, then exposes a single exchange(envelope, phase) primitive the bridge calls once per protocol step. Each invocation queues exactly one outgoing frame, invokes _start, and pops exactly one reply frame.

The runner intentionally does NOT model a long-running guest loop. WebAssembly's JS API is strictly synchronous and would force a busy- spin if the guest tried to block on empty stdin. The one-shot model keeps every step bounded and re-entrant for the next request within the same instance.

Features

  • Self-instantiates the WASM module — the runner is the source of truth for the WASI snapshot it wires.
  • Stdin/stdout backed by createWasiStdioStreams; stderr lines forward to a WasiStdioLoggerSink.
  • proc_exit ignored (not treated as a trap) — Javy-compiled plugins call it after every _start.
  • Per-step deadline via stdioTimeoutMs (default 5s); a missing reply frame surfaces as SAND_WASM_BRIDGE.STDIO_TIMEOUT with phase and elapsed milliseconds in the error context.
  • Trap propagation honours the seven-kind classification from classifyTrap — unreachable mid-handler surfaces as HANDLER_TRAPPED with trapKind: "unreachable".
  • dispose() is idempotent and chains through to the optional WasmSandboxHandle it was constructed with.
  • extraImports lets the bridge wire suaveplan_caps.* capability imports from the sandbox into the runner's parallel instance so SAND_PERM.DENIED flows continue working under wasi-stdio.

Usage: Basic

import { createWasiStdioRunner } from
    "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";

declare const module: WebAssembly.Module;
const runner = createWasiStdioRunner({
    pluginName: "example",
    wasmModule: module,
});
const reply = runner.exchange({ kind: "routes" }, "routes");
if (reply.kind === "routes") {
    for (const route of reply.routes) {
        void route;
    }
}
runner.dispose();

Usage: Advanced

import { createWasiStdioRunner } from
    "@suaveplan/api-sandbox-wasm-host-bridge/wasi-stdio";
import type { LoggerLike } from "@suaveplan/types";

declare const module: WebAssembly.Module;
declare const logger: LoggerLike;
declare const capabilityImports: WebAssembly.ModuleImports;

const runner = createWasiStdioRunner({
    pluginName: "ex",
    wasmModule: module,
    logger,
    stdioTimeoutMs: 30_000,
    extraImports: { suaveplan_caps: capabilityImports },
});
try {
    const ok = runner.exchange({ kind: "start" }, "start");
    if (ok.kind !== "ok") {
        throw new Error(`unexpected reply ${ok.kind}`);
    }
    declare function buildRequest(): never;
    const response = runner.exchange(
        { kind: "request", request: buildRequest() },
        "request",
    );
    void response;
} finally {
    runner.dispose();
}

API Reference

createWasiStdioRunner(deps): WasiStdioRunner

  • deps.pluginName: string — plugin name (error context label).
  • deps.wasmModule: WebAssembly.Module — pre-compiled module.
  • deps.logger?: WasiStdioLoggerSink — stderr line sink.
  • deps.stdioTimeoutMs?: number — per-step deadline (default 5000).
  • deps.sandbox?: { dispose; disposed } — sandbox dispose chain.
  • deps.extraImports?: Record<string, ModuleImports> — composed with the runner's WASI snapshot at instantiate time.

WasiStdioRunner.exchange(envelope, phase): PluginEnvelopeOut

Sends one envelope, invokes _start, pops one reply frame. Throws:

  • SAND_WASM_BRIDGE.HANDLER_TRAPPED (with trapKind + phase) on WASM traps during _start.
  • SAND_WASM_BRIDGE.STDIO_TIMEOUT when no reply frame is buffered after _start returns.
  • SAND_WASM_BRIDGE.LIFECYCLE_FAILED if called after dispose().
  • SAND_WASM_BRIDGE.FRAME_OVERSIZE / MARSHAL_FAILED / PROTOCOL_MISMATCH from the framer when encoding/decoding fails.

WasiStdioRunner.dispose(): void

Idempotent; chains through to deps.sandbox.dispose().

Implementation Notes

  • The runner wires a minimal WASI snapshot in-process — it does NOT delegate fs/path/preopens to @suaveplan/api-sandbox-wasm's createWasiSnapshot, because those imports are not part of the WASI-stdio variant's contract (Javy-compiled plugins use stdin/ stdout exclusively). When permission-policy preopens DO matter for a hybrid plugin, the consumer composes the additional imports via extraImports.
  • proc_exit is treated as a normal return. Most WASI runtimes throw when _start calls proc_exit non-zero, but Javy's pattern is to call proc_exit(0) after writing the reply; treating it as a no-op keeps the guest's intent intact.
  • STDIO_TIMEOUT triggers when _start returns without writing any frame to fd 1. It is not driven by a wall-clock timer — WebAssembly is synchronous, so there is no thread to interrupt. The elapsedMs context field carries the observed _start duration so dashboards can spot near-misses.
  • dispose() resets every buffer in addition to chaining the sandbox dispose. Resetting first guarantees that a future GC cycle on the runner does not keep the guest's memory pinned through a closure.