Initial Commit
This commit is contained in:
482
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/wasi-stdio/build-wasm.ts
vendored
Normal file
482
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/wasi-stdio/build-wasm.ts
vendored
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Hand-encoded WASM fixture builder for the WASI-stdio variant of the
|
||||
* bridge. Produces a validator-clean module that imports the WASI
|
||||
* snapshot-preview1 surface the bridge expects (`fd_read`, `fd_write`,
|
||||
* `environ_get`, `environ_sizes_get`, `proc_exit`) and exports the
|
||||
* required `memory` + `_start` pair.
|
||||
*
|
||||
* Per-call protocol: the guest's `_start` is invoked once per envelope
|
||||
* exchange by the host. Each invocation:
|
||||
* 1. Calls `fd_read` to drain stdin into a scratch buffer at offset
|
||||
* 0 (the host queues exactly one frame before calling `_start`,
|
||||
* so the read returns the full frame).
|
||||
* 2. Optionally executes `unreachable` (trap-on-call-N variants).
|
||||
* 3. Picks one of up to four pre-staged reply frames based on a
|
||||
* mutable global call counter.
|
||||
* 4. Calls `fd_write(fd=1, ...)` with the chosen reply frame.
|
||||
* 5. Increments the call counter and returns.
|
||||
*
|
||||
* The four reply slots map to the protocol steps in order:
|
||||
* slot 0 → routes reply
|
||||
* slot 1 → start ok
|
||||
* slot 2 → request reply (or trap, per `trapOnRequest`)
|
||||
* slot 3 → dispose ok
|
||||
*
|
||||
* Memory layout (single 1-page memory, 64 KiB):
|
||||
* [ 0, 16) — fd_read iov[0] = { ptr=128, len=4096 }
|
||||
* fd_write iov[0] = { ptr=?, len=? } (overwritten)
|
||||
* [ 16, 20) — nread / nwritten scratch
|
||||
* [ 128, 4224) — stdin scratch buffer
|
||||
* [5000, 5004) — call counter (global stash)
|
||||
* [8192, 8192 + reply0Bytes.length) — routes reply
|
||||
* [12288, 12288 + reply1Bytes.length) — start ok
|
||||
* [16384, 16384 + reply2Bytes.length) — request reply
|
||||
* [20480, 20480 + reply3Bytes.length) — dispose ok
|
||||
*
|
||||
* Globals:
|
||||
* global 0 (mut i32) — call counter (default 0).
|
||||
*/
|
||||
|
||||
import {
|
||||
concat,
|
||||
encodeI32,
|
||||
encodeName,
|
||||
encodeU32,
|
||||
section,
|
||||
vec,
|
||||
} from "../leb128.js";
|
||||
import {
|
||||
BLOCK_TYPE_EMPTY,
|
||||
funcBody,
|
||||
OP_BLOCK,
|
||||
OP_CALL,
|
||||
OP_DROP,
|
||||
OP_END,
|
||||
OP_I32_ADD,
|
||||
OP_I32_CONST,
|
||||
OP_I32_EQZ,
|
||||
OP_I32_STORE,
|
||||
OP_I32_SUB,
|
||||
OP_UNREACHABLE,
|
||||
opBrIf,
|
||||
opGlobalGet,
|
||||
opGlobalSet,
|
||||
opI32Const,
|
||||
opLocalGet,
|
||||
opLocalSet,
|
||||
VT_I32,
|
||||
} from "../wasm-opcodes.js";
|
||||
|
||||
const STDIN_PTR = 128;
|
||||
const STDIN_LEN = 4096;
|
||||
const NREAD_PTR = 16;
|
||||
|
||||
// Each reply slot has 4096 bytes of headroom.
|
||||
const REPLY_SLOTS = [8192, 12288, 16384, 20480] as const;
|
||||
|
||||
const MAGIC = new Uint8Array([0x00, 0x61, 0x73, 0x6d]);
|
||||
const VERSION = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
|
||||
|
||||
const ENCODER = new TextEncoder();
|
||||
|
||||
/** Configurable behaviour for the WASI-stdio fixture. */
|
||||
export interface BuildWasiStdioFixtureOptions {
|
||||
/** JSON-encoded routes reply payload. Default: 1 route. */
|
||||
readonly routesReply?: string;
|
||||
/** JSON-encoded ok-shaped reply for `{kind:"start"}`. Default: `{"kind":"ok"}`. */
|
||||
readonly startReply?: string;
|
||||
/** JSON-encoded request reply (PluginEnvelopeOut). Default: 200 ok body. */
|
||||
readonly requestReply?: string;
|
||||
/** JSON-encoded reply for `{kind:"dispose"}`. Default: `{"kind":"ok"}`. */
|
||||
readonly disposeReply?: string;
|
||||
/** If set, `_start` invokes `unreachable` on the given (0-indexed) call. */
|
||||
readonly trapOnCall?: 0 | 1 | 2 | 3;
|
||||
/** Drop one of the required imports — exercises the ABI validator. */
|
||||
readonly dropImports?: readonly (
|
||||
| "fd_read"
|
||||
| "fd_write"
|
||||
| "environ_get"
|
||||
| "environ_sizes_get"
|
||||
| "proc_exit"
|
||||
)[];
|
||||
/** Drop a required export — exercises the ABI validator. */
|
||||
readonly dropExports?: readonly ("memory" | "_start")[];
|
||||
/** Emit a malformed (non-JSON) reply on the request slot. */
|
||||
readonly requestReplyRaw?: Uint8Array;
|
||||
/**
|
||||
* Make `_start` return without writing any frame to fd 1 — used by
|
||||
* the runner's STDIO_TIMEOUT test.
|
||||
*/
|
||||
readonly silentStart?: boolean;
|
||||
/**
|
||||
* Add an extra import from an unknown module — used by the
|
||||
* runner's instantiation_failed test.
|
||||
*/
|
||||
readonly addExtraUnknownImport?: boolean;
|
||||
/**
|
||||
* Have `_start` invoke `environ_sizes_get` (via memoryRef) once
|
||||
* per call. Used to drive the runner's memoryRef thunk coverage.
|
||||
*/
|
||||
readonly callEnvironSizesGet?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ROUTES_REPLY = JSON.stringify({
|
||||
kind: "routes",
|
||||
routes: [
|
||||
{ method: "GET", path: "/hello" },
|
||||
{ method: "POST", path: "/echo" },
|
||||
],
|
||||
});
|
||||
const DEFAULT_OK_REPLY = JSON.stringify({ kind: "ok" });
|
||||
const DEFAULT_REQUEST_REPLY = JSON.stringify({
|
||||
kind: "response",
|
||||
response: {
|
||||
status: 200,
|
||||
headers: [["content-type", "text/plain"]],
|
||||
bodyBase64: btoa("ok")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, ""),
|
||||
},
|
||||
});
|
||||
|
||||
/** Build a length-prefixed frame `[u32 BE len][JSON bytes]`. */
|
||||
function frame(json: string | Uint8Array): Uint8Array {
|
||||
const payload = typeof json === "string" ? ENCODER.encode(json) : json;
|
||||
const out = new Uint8Array(4 + payload.length);
|
||||
new DataView(out.buffer).setUint32(0, payload.length, false);
|
||||
out.set(payload, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WASI-stdio fixture WASM module. The returned bytes are
|
||||
* accepted by `WebAssembly.compile` and instantiate against an imports
|
||||
* object exposing `wasi_snapshot_preview1.{fd_read, fd_write,
|
||||
* environ_get, environ_sizes_get, proc_exit}`.
|
||||
*/
|
||||
export function buildWasiStdioFixtureWasm(
|
||||
options: BuildWasiStdioFixtureOptions = {}
|
||||
): Uint8Array {
|
||||
const replies: ReadonlyArray<Uint8Array> = [
|
||||
frame(options.routesReply ?? DEFAULT_ROUTES_REPLY),
|
||||
frame(options.startReply ?? DEFAULT_OK_REPLY),
|
||||
options.requestReplyRaw !== undefined
|
||||
? frame(options.requestReplyRaw)
|
||||
: frame(options.requestReply ?? DEFAULT_REQUEST_REPLY),
|
||||
frame(options.disposeReply ?? DEFAULT_OK_REPLY),
|
||||
];
|
||||
for (let i = 0; i < replies.length; i++) {
|
||||
const r = replies[i] as Uint8Array;
|
||||
if (r.length > 4096) {
|
||||
throw new RangeError(`reply slot ${i} payload too large`);
|
||||
}
|
||||
}
|
||||
const dropImports = new Set(options.dropImports ?? []);
|
||||
const dropExports = new Set(options.dropExports ?? []);
|
||||
|
||||
// Type section.
|
||||
// 0: (i32, i32, i32, i32) → i32 — fd_read / fd_write signature.
|
||||
// 1: (i32, i32) → i32 — environ_get / environ_sizes_get.
|
||||
// 2: (i32) → () — proc_exit.
|
||||
// 3: () → () — _start.
|
||||
const typeSection = section(
|
||||
0x01,
|
||||
vec([
|
||||
new Uint8Array([
|
||||
0x60,
|
||||
0x04,
|
||||
VT_I32,
|
||||
VT_I32,
|
||||
VT_I32,
|
||||
VT_I32,
|
||||
0x01,
|
||||
VT_I32,
|
||||
]),
|
||||
new Uint8Array([0x60, 0x02, VT_I32, VT_I32, 0x01, VT_I32]),
|
||||
new Uint8Array([0x60, 0x01, VT_I32, 0x00]),
|
||||
new Uint8Array([0x60, 0x00, 0x00]),
|
||||
])
|
||||
);
|
||||
|
||||
// Import section.
|
||||
const importEntries: Uint8Array[] = [];
|
||||
const pushImport = (name: string, typeIdx: number): void => {
|
||||
if (dropImports.has(name as never)) return;
|
||||
importEntries.push(
|
||||
concat(
|
||||
encodeName("wasi_snapshot_preview1"),
|
||||
encodeName(name),
|
||||
new Uint8Array([0x00]), // kind = function
|
||||
encodeU32(typeIdx)
|
||||
)
|
||||
);
|
||||
};
|
||||
pushImport("fd_read", 0);
|
||||
pushImport("fd_write", 0);
|
||||
pushImport("environ_get", 1);
|
||||
pushImport("environ_sizes_get", 1);
|
||||
pushImport("proc_exit", 2);
|
||||
if (options.addExtraUnknownImport === true) {
|
||||
importEntries.push(
|
||||
concat(
|
||||
encodeName("unknown_module"),
|
||||
encodeName("missing_fn"),
|
||||
new Uint8Array([0x00]),
|
||||
encodeU32(2)
|
||||
)
|
||||
);
|
||||
}
|
||||
const importedCount = importEntries.length;
|
||||
const importSection = section(0x02, vec(importEntries));
|
||||
|
||||
// Function section — one defined function: `_start` (type 3).
|
||||
const funcSection = section(0x03, vec([encodeU32(3)]));
|
||||
|
||||
// Memory section — 1 page (64 KiB).
|
||||
const memorySection = section(0x05, vec([new Uint8Array([0x00, 0x01])]));
|
||||
|
||||
// Global section — global 0 (mut i32) = 0.
|
||||
const globalSection = section(
|
||||
0x06,
|
||||
vec([
|
||||
concat(
|
||||
new Uint8Array([VT_I32, 0x01]),
|
||||
new Uint8Array([OP_I32_CONST]),
|
||||
encodeI32(0),
|
||||
new Uint8Array([OP_END])
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
// Export section.
|
||||
const exportEntries: Uint8Array[] = [];
|
||||
if (!dropExports.has("memory")) {
|
||||
exportEntries.push(
|
||||
concat(encodeName("memory"), new Uint8Array([0x02, 0x00]))
|
||||
);
|
||||
}
|
||||
if (!dropExports.has("_start")) {
|
||||
// _start is the only defined function — its index is `importedCount + 0`.
|
||||
exportEntries.push(
|
||||
concat(
|
||||
encodeName("_start"),
|
||||
new Uint8Array([0x00]),
|
||||
encodeU32(importedCount)
|
||||
)
|
||||
);
|
||||
}
|
||||
const exportSection = section(0x07, vec(exportEntries));
|
||||
|
||||
// Code section — _start body.
|
||||
const startBody = buildStartBody({
|
||||
importedCount,
|
||||
replies,
|
||||
trapOnCall: options.trapOnCall,
|
||||
haveFdRead: !dropImports.has("fd_read"),
|
||||
haveFdWrite:
|
||||
!dropImports.has("fd_write") && options.silentStart !== true,
|
||||
callEnvironSizesGet: options.callEnvironSizesGet === true,
|
||||
});
|
||||
const codeSection = section(0x0a, vec([startBody]));
|
||||
|
||||
// Data section — pre-stage each reply frame at its slot offset.
|
||||
const dataSegments: Uint8Array[] = [];
|
||||
for (let i = 0; i < replies.length; i++) {
|
||||
const slot = REPLY_SLOTS[i] as number;
|
||||
dataSegments.push(
|
||||
concat(
|
||||
encodeU32(0),
|
||||
new Uint8Array([OP_I32_CONST]),
|
||||
encodeI32(slot),
|
||||
new Uint8Array([OP_END]),
|
||||
encodeU32((replies[i] as Uint8Array).length),
|
||||
replies[i] as Uint8Array
|
||||
)
|
||||
);
|
||||
}
|
||||
const dataSection = section(0x0b, vec(dataSegments));
|
||||
|
||||
return concat(
|
||||
MAGIC,
|
||||
VERSION,
|
||||
typeSection,
|
||||
importSection,
|
||||
funcSection,
|
||||
memorySection,
|
||||
globalSection,
|
||||
exportSection,
|
||||
codeSection,
|
||||
dataSection
|
||||
);
|
||||
}
|
||||
|
||||
interface StartBodyOptions {
|
||||
readonly importedCount: number;
|
||||
readonly replies: ReadonlyArray<Uint8Array>;
|
||||
readonly trapOnCall: number | undefined;
|
||||
readonly haveFdRead: boolean;
|
||||
readonly haveFdWrite: boolean;
|
||||
readonly callEnvironSizesGet: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `_start` function body. Locals: `local 0` = call index
|
||||
* (cached from global). The function logic is:
|
||||
*
|
||||
* local0 = global0
|
||||
* if (trapOnCall === local0) unreachable
|
||||
*
|
||||
* // 1. fd_read(fd=0, iovs=stdinIovs, iovsLen=1, nreadPtr=NREAD_PTR)
|
||||
* // 2. select reply slot by local0 (4-arm switch)
|
||||
* // 3. fd_write(fd=1, iovs=writeIovs, iovsLen=1, nwrittenPtr=NREAD_PTR)
|
||||
* //
|
||||
* // global0 = local0 + 1
|
||||
* //
|
||||
* The fixture writes the WHOLE pre-staged frame on every call. We use
|
||||
* scratch memory cells to set up iovec records before each WASI call.
|
||||
*/
|
||||
function buildStartBody(opts: StartBodyOptions): Uint8Array {
|
||||
const { importedCount, replies, trapOnCall, haveFdRead, haveFdWrite } =
|
||||
opts;
|
||||
|
||||
const _fdReadIdx = importedCount > 0 ? 0 : -1; // first imported = fd_read
|
||||
const fdWriteIdx = 1;
|
||||
void fdWriteIdx;
|
||||
|
||||
// Iovec scratch at memory[0..16]:
|
||||
// [ 0, 4) = ptr (stdinPtr=128 for fd_read; replySlot for fd_write)
|
||||
// [ 4, 8) = len (STDIN_LEN for fd_read; reply.length for fd_write)
|
||||
//
|
||||
// NREAD_PTR / NWRITTEN_PTR scratch at memory[16..20].
|
||||
|
||||
// 1) fd_read iovec setup
|
||||
const setupReadIov = concat(
|
||||
opI32Const(0),
|
||||
opI32Const(STDIN_PTR),
|
||||
new Uint8Array([OP_I32_STORE]),
|
||||
encodeU32(2),
|
||||
encodeU32(0),
|
||||
opI32Const(4),
|
||||
opI32Const(STDIN_LEN),
|
||||
new Uint8Array([OP_I32_STORE]),
|
||||
encodeU32(2),
|
||||
encodeU32(0)
|
||||
);
|
||||
|
||||
// fd_read call: result = fd_read(0, 0, 1, NREAD_PTR); drop;
|
||||
const callFdRead = concat(
|
||||
opI32Const(0), // fd = 0 (stdin)
|
||||
opI32Const(0), // iovs ptr = 0
|
||||
opI32Const(1), // iovs len
|
||||
opI32Const(NREAD_PTR), // nread ptr
|
||||
new Uint8Array([OP_CALL]),
|
||||
encodeU32(0), // imported fd_read at function index 0 (if not dropped)
|
||||
new Uint8Array([OP_DROP])
|
||||
);
|
||||
|
||||
// 4-arm dispatch: local 0 holds the call index.
|
||||
// We use chained br_if blocks: if local0 === 0 → load slot 0 + jump out, etc.
|
||||
|
||||
const dispatchBlocks: Uint8Array[] = [];
|
||||
for (let slot = 0; slot < 4; slot++) {
|
||||
const replyLen = replies[slot]?.length ?? 0;
|
||||
const replyPtr = REPLY_SLOTS[slot] as number;
|
||||
// Block: if local0 !== slot, br 0 (skip); else set iovec + br outer.
|
||||
// Encoded as a single block with a conditional skip.
|
||||
// We'll encode each arm as: (block (local.get 0) (i32.const slot) (i32.sub) (br_if 0) <setup+write> (br outer))
|
||||
// outer block depth: we'll wrap the whole switch in one outer block (depth 1).
|
||||
const arm = concat(
|
||||
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
|
||||
opLocalGet(0),
|
||||
opI32Const(slot),
|
||||
new Uint8Array([OP_I32_SUB]),
|
||||
opBrIf(0), // if local0 - slot !== 0 (truthy) → break inner block
|
||||
// Setup write iovec: mem[0..4] = replyPtr; mem[4..8] = replyLen
|
||||
opI32Const(0),
|
||||
opI32Const(replyPtr),
|
||||
new Uint8Array([OP_I32_STORE]),
|
||||
encodeU32(2),
|
||||
encodeU32(0),
|
||||
opI32Const(4),
|
||||
opI32Const(replyLen),
|
||||
new Uint8Array([OP_I32_STORE]),
|
||||
encodeU32(2),
|
||||
encodeU32(0),
|
||||
// Call fd_write(1, 0, 1, NREAD_PTR); drop.
|
||||
opI32Const(1),
|
||||
opI32Const(0),
|
||||
opI32Const(1),
|
||||
opI32Const(NREAD_PTR),
|
||||
new Uint8Array([OP_CALL]),
|
||||
encodeU32(1), // imported fd_write at function index 1
|
||||
new Uint8Array([OP_DROP]),
|
||||
// Break the OUTER switch block (depth 1: we're inside arm-block inside outer).
|
||||
new Uint8Array([0x0c, 0x01]), // OP_BR depth=1
|
||||
new Uint8Array([OP_END])
|
||||
);
|
||||
dispatchBlocks.push(arm);
|
||||
}
|
||||
|
||||
const switchBody = concat(
|
||||
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
|
||||
...dispatchBlocks,
|
||||
new Uint8Array([OP_END])
|
||||
);
|
||||
|
||||
// Optional trap injection: if trapOnCall === local0, hit unreachable.
|
||||
let trapGuard: Uint8Array = new Uint8Array();
|
||||
if (trapOnCall !== undefined) {
|
||||
trapGuard = concat(
|
||||
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
|
||||
opLocalGet(0),
|
||||
opI32Const(trapOnCall),
|
||||
new Uint8Array([OP_I32_SUB]),
|
||||
new Uint8Array([OP_I32_EQZ]),
|
||||
new Uint8Array([OP_I32_EQZ]),
|
||||
opBrIf(0), // skip unreachable if !== trapOnCall
|
||||
new Uint8Array([OP_UNREACHABLE]),
|
||||
new Uint8Array([OP_END])
|
||||
);
|
||||
}
|
||||
|
||||
// Increment the call counter EARLY so a trap on call N does not
|
||||
// leave global0 stuck at N (which would re-trap dispose).
|
||||
const incrementCounter = concat(
|
||||
opLocalGet(0),
|
||||
opI32Const(1),
|
||||
new Uint8Array([OP_I32_ADD]),
|
||||
opGlobalSet(0)
|
||||
);
|
||||
|
||||
// Optional: invoke environ_sizes_get(20, 24) — pushes the
|
||||
// memoryRef thunk in the runner. environ_sizes_get is import #3
|
||||
// (fd_read=0, fd_write=1, environ_get=2, environ_sizes_get=3).
|
||||
const optionalEnvironCall = opts.callEnvironSizesGet
|
||||
? concat(
|
||||
opI32Const(20),
|
||||
opI32Const(24),
|
||||
new Uint8Array([OP_CALL]),
|
||||
encodeU32(3),
|
||||
new Uint8Array([OP_DROP])
|
||||
)
|
||||
: new Uint8Array();
|
||||
|
||||
// Compose: load counter; increment; trap guard; fd_read; switch.
|
||||
const loadCounter = concat(opGlobalGet(0), opLocalSet(0));
|
||||
|
||||
const body = concat(
|
||||
loadCounter,
|
||||
incrementCounter,
|
||||
trapGuard,
|
||||
haveFdRead ? concat(setupReadIov, callFdRead) : new Uint8Array(),
|
||||
optionalEnvironCall,
|
||||
haveFdWrite ? switchBody : new Uint8Array()
|
||||
);
|
||||
|
||||
return funcBody([{ count: 1, type: VT_I32 }], body);
|
||||
}
|
||||
|
||||
// Re-export the slot constants for tests that need to introspect memory.
|
||||
export const WASI_STDIO_FIXTURE_REPLY_SLOTS = REPLY_SLOTS;
|
||||
export const WASI_STDIO_FIXTURE_STDIN_PTR = STDIN_PTR;
|
||||
Reference in New Issue
Block a user