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 aWasiStdioLoggerSink. proc_exitignored (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 asSAND_WASM_BRIDGE.STDIO_TIMEOUTwithphaseand elapsed milliseconds in the error context. - Trap propagation honours the seven-kind classification from
classifyTrap—unreachablemid-handler surfaces asHANDLER_TRAPPEDwithtrapKind: "unreachable". dispose()is idempotent and chains through to the optionalWasmSandboxHandleit was constructed with.extraImportslets the bridge wiresuaveplan_caps.*capability imports from the sandbox into the runner's parallel instance soSAND_PERM.DENIEDflows 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(withtrapKind+phase) on WASM traps during_start.SAND_WASM_BRIDGE.STDIO_TIMEOUTwhen no reply frame is buffered after_startreturns.SAND_WASM_BRIDGE.LIFECYCLE_FAILEDif called afterdispose().SAND_WASM_BRIDGE.FRAME_OVERSIZE/MARSHAL_FAILED/PROTOCOL_MISMATCHfrom 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'screateWasiSnapshot, 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 viaextraImports. proc_exitis treated as a normal return. Most WASI runtimes throw when_startcallsproc_exitnon-zero, but Javy's pattern is to callproc_exit(0)after writing the reply; treating it as a no-op keeps the guest's intent intact.STDIO_TIMEOUTtriggers when_startreturns 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. TheelapsedMscontext field carries the observed_startduration 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.