# `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 ```ts 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 ```ts 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` — 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.