first commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# `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<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.
|
||||
Reference in New Issue
Block a user