From 4e89489359eb8ba0a29bc6b8a3e62aab362d295d Mon Sep 17 00:00:00 2001 From: Kinflou Date: Tue, 27 Oct 2026 00:28:45 +0800 Subject: [PATCH 1/3] ffi/js: poison the instance on a trap, cache the regions, load sync and streaming, size encodes WIP --- Cargo.toml | 16 + runtime/ffi/js/comline.d.mts | 66 ++- runtime/ffi/js/comline.mjs | 707 +++++++++++++++++++++------ runtime/ffi/js/test/env.mjs | 23 + runtime/ffi/js/test/poison.test.mjs | 192 ++++++++ runtime/ffi/js/test/regions.test.mjs | 162 ++++++ runtime/ffi/js/test/vectors.test.mjs | 175 ++++--- runtime/ffi/src/lib.rs | 41 +- 8 files changed, 1174 insertions(+), 208 deletions(-) create mode 100644 runtime/ffi/js/test/env.mjs create mode 100644 runtime/ffi/js/test/poison.test.mjs create mode 100644 runtime/ffi/js/test/regions.test.mjs diff --git a/Cargo.toml b/Cargo.toml index ac8b263..6dca0a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,22 @@ exclude = ["runtime/fuzz"] [profile.release] opt-level = 2 +# The wasm32 build of `comline-runtime-ffi`, for the JS glue: +# cargo build -p comline-runtime-ffi --profile wasm --target wasm32-unknown-unknown +# For wasm32 only: native builds keep `release`, and the instruction-count +# benches `bench`. One codegen unit and fat LTO let the core inline into +# each export. `panic = "abort"` is what wasm32-unknown-unknown does anyway +# (a panic is a trap, which poisons the whole instance; see +# runtime/ffi/src/lib.rs), so it changes nothing there. Never use this +# profile natively, where the ABI's guards need a panic to unwind. The +# sizes and timings it was chosen by are in runtime/ffi/README.md. +[profile.wasm] +inherits = "release" +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" + # The instruction-count benches: one codegen unit, so a change to one function # can't move how an unrelated one is partitioned, inlined and counted. [profile.bench] diff --git a/runtime/ffi/js/comline.d.mts b/runtime/ffi/js/comline.d.mts index f3dd737..c044a7e 100644 --- a/runtime/ffi/js/comline.d.mts +++ b/runtime/ffi/js/comline.d.mts @@ -6,6 +6,13 @@ // `comline-runtime-ffi` (`.d.mts`, the name TypeScript looks for beside a // `.mjs`). Each method is one `comline.h` function; see there for what it // does and when it fails. +// +// On wasm32 a trap (a panic in the module) poisons the whole instance, not +// just the handle: the call that trapped returns `Status.ERR_POISONED`, and +// so does every later call on the `Comline` and on every `Connection` from +// it, without entering the module. Drop them and instantiate again. A +// poisoned result has the fields its type always has zeroed; `supports` is +// false. See `comline.mjs`. export declare const ABI_MAJOR: 0; export declare const ABI_MINOR: 1; @@ -107,8 +114,64 @@ export interface Fed { * check its ABI version and the `comline_event` layout. */ export declare function instantiate(source: BufferSource | WebAssembly.Module): Promise; +/** `instantiate`, synchronously, with the same checks. For Node and + * workers: a browser's main thread refuses to compile a module this size + * synchronously. */ +export declare function instantiateSync(source: BufferSource | WebAssembly.Module): Comline; + +/** Instantiate from a `fetch` response, compiling as it downloads, with the + * same checks. Falls back to reading the whole body when the engine has no + * `WebAssembly.instantiateStreaming` or the response's content type isn't + * `application/wasm`; a response that isn't `ok` is refused. */ +export declare function instantiateStreaming( + source: Response | PromiseLike, +): Promise; + +/** What `encodeBound` sizes. Leaving out a field it can use gives a looser + * bound, never a smaller one. */ +export type EncodeBoundArgs = { + framing: "datagram@2" | "jsonrpc@1"; + /** The connection's `stream`: a 4-byte prefix is counted. */ + stream?: boolean; + /** The body's length, as given to the encode. */ + bodyLen: number; + /** `datagram@2`: exact with it, the varint's most (3 bytes) without. */ + protocol?: number; +} & ( + | { + kind: "request"; + /** `jsonrpc@1`: the method name, counted escaped exactly. */ + name?: Bytes; + /** `jsonrpc@1`, without `name`: its UTF-8 length, counted at 6 bytes + * a byte (`\u00XX`). */ + nameLen?: number; + /** `jsonrpc@1`: a one-way call (`false`) has no `id`. Counted with + * one when left out. */ + expectsReply?: boolean | number; + /** `jsonrpc@1`: the id the request will be given, when known; 20 + * digits otherwise. */ + requestId?: bigint | number; + } + | { + kind: "reply"; + envelope: number; + /** `jsonrpc@1`: the request id answered; 20 digits without it. */ + requestId?: bigint | number; + /** `jsonrpc@1`, `Envelope.ERR`: the error id; 5 digits without it. */ + errorId?: number; + } +); + +/** The most bytes an encode can write (`*written`, a stream's prefix + * included), to size `cap` so it never retries: never smaller than what + * the ABI writes, and exactly that when every field the frame holds is + * given. Pure; needs no instance. */ +export declare function encodeBound(args: EncodeBoundArgs): number; + export declare class Comline { private constructor(); + /** Whether a trap killed this instance. */ + readonly poisoned: boolean; abiVersion(): number; supports(name: Bytes): boolean; nameHash(name: Bytes): { status: Status; hash?: bigint }; @@ -126,7 +189,8 @@ export declare class Comline { } /** A `comline_connection *`. Free it with `free()`; nothing frees it for - * you. */ + * you. Once its instance is poisoned, every call returns `ERR_POISONED` + * and `free()` does nothing. */ export declare class Connection { private constructor(); start(cap: number): Written; diff --git a/runtime/ffi/js/comline.mjs b/runtime/ffi/js/comline.mjs index d286f92..8c98c7a 100644 --- a/runtime/ffi/js/comline.mjs +++ b/runtime/ffi/js/comline.mjs @@ -5,14 +5,21 @@ // The JS glue over the wasm32 build of `comline-runtime-ffi`: the same // functions `comline.h` declares, for a host that has no pointers. It is a // thin, faithful wrapper: every call is one ABI call, and returns the -// status with whatever the call wrote; it retries nothing, throws on no -// status, and adds no state. A binding (exceptions, async, codecs) builds on -// it. No dependencies, no wasm-bindgen. +// status with whatever the call wrote; it retries nothing and throws on no +// status. The only state it keeps is the module's, never the protocol's: +// where the memory regions are, and whether the instance is alive. A +// binding (exceptions, async, codecs) builds on it. No dependencies, no +// wasm-bindgen. // // import { instantiate, Status } from "./comline.mjs"; // const comline = await instantiate(await readFile("comline_runtime_ffi.wasm")); // const { status, connection } = comline.connectionNew({ framing: "datagram@2", handshake: Handshake.NONE, maxWaiting: 16 }); // +// Loading: `instantiate` (bytes or a compiled module), `instantiateSync` +// (the same, synchronously, for Node and workers) and +// `instantiateStreaming` (a `fetch` response, compiled as it downloads). +// All three check the module the same way before handing it out. +// // How memory crosses: the module owns two regions, `in` and `out` // (`comline_wasm_reserve_in/out`). A call copies its inputs (names, bodies, // the config) into `in`, gives the ABI pointers into `out` for everything it @@ -21,13 +28,42 @@ // regions never do. Byte outputs are fresh copies; nothing returned points // into wasm memory. // +// A region is reserved only when a call needs more than it holds, so a call +// is normally one crossing. That rests on what the module promises +// (`src/lib.rs`, `mod wasm`): a region moves, grows or is freed only inside +// its own reserve call, and only when that call asks for more than it +// holds; a reserve for no more returns the same address and changes +// nothing. So the addresses cached here stay valid from call to call. +// Memory itself may grow during any call (a reserve, or an allocation such +// as `connectionNew`'s), which detaches the `ArrayBuffer` views on it +// without moving anything, so every access goes through views taken again +// whenever `memory.buffer` has changed. +// +// Traps poison the instance. On wasm32 a panic in the module is a trap, not +// an unwind: the ABI's `catch_unwind` can't catch it, so the module itself +// never returns `ERR_POISONED`, and the trap leaves the whole instance (its +// stack pointer, its heap, every connection in it) in an unknown state. +// The glue marks the `Comline` instance dead on a trap: a +// `WebAssembly.RuntimeError`, or a `RangeError` from inside the module (the +// engine's stack exhaustion stops it just as hard). The call that trapped +// returns `Status.ERR_POISONED`, and so does every later call on that +// instance and on every `Connection` made from it, without entering the +// module. Natively the C ABI poisons only the handle that panicked, because +// a panic unwinds to its guard; here the instance is poisoned, not just the +// handle. Drop the instance and its connections, and instantiate again. Any +// other exception (a `TypeError` for an argument of the wrong type) is +// thrown as it was, and poisons nothing; so is the `RangeError` for memory +// that can't grow, which leaves the module as it was. +// // Numbers: a `u64` is a BigInt both ways; everything else is a Number. // Names (framings, wire formats, methods) are a string, encoded as UTF-8, -// or a Uint8Array of the bytes as they are. A panic in the module is a trap -// on wasm32, thrown as a `WebAssembly.RuntimeError`: drop the instance. +// or a Uint8Array of the bytes as they are. // // Where a call's out-pointer is written only on some outcomes, the result -// leaves that field out (or null) when the ABI didn't write it. +// leaves that field out (or null) when the ABI didn't write it. A poisoned +// call's result is the status, with the fields its type always has zeroed +// (`written` and `consumed` 0, a zeroed `event`, `peer` and `connection` +// null). `supports` is false then, as the C function is when it panics. /** The ABI version this glue is written against. Before 1.0 a minor counts * as a major, so `instantiate` accepts exactly this. */ @@ -93,6 +129,11 @@ const CONFIG = { }; const HANDSHAKE_SIZE = 32; +/** The least a region is reserved at, so small calls never reserve again. */ +const REGION_MIN = 1024; + +const POISONED = Status.ERR_POISONED; + const utf8 = new TextEncoder(); /** Bytes of a name or body: a string as UTF-8, bytes as they are. */ @@ -105,6 +146,11 @@ function bytesOf(value) { const align8 = (n) => (n + 7) & ~7; +/** A `u64` argument as the ABI takes it: a BigInt, wrapped to 64 bits. */ +const u64 = (value) => BigInt.asUintN(64, BigInt(value)); + +// ── loading ──────────────────────────────────────────────────────────────── + /** * Instantiate the wasm32 build and check it: its ABI version must be * `ABI_MAJOR.ABI_MINOR`, and one fed frame must land in `comline_event` at @@ -119,19 +165,253 @@ export async function instantiate(source) { return new Comline(instance.exports); } +/** + * `instantiate`, synchronously, with the same checks. For Node and + * workers: a browser's main thread refuses to compile a module of this size + * synchronously, so use `instantiateStreaming` or `instantiate` there (or + * pass a module compiled elsewhere). + * + * @param {BufferSource | WebAssembly.Module} source + */ +export function instantiateSync(source) { + const module = source instanceof WebAssembly.Module ? source : new WebAssembly.Module(source); + return new Comline(new WebAssembly.Instance(module, {}).exports); +} + +/** + * Instantiate from a `fetch` response (or a promise of one), compiling it + * as it downloads, with the same checks as `instantiate`. + * + * `WebAssembly.instantiateStreaming` takes only a response served as + * `application/wasm`. When the engine has no streaming compile, or the + * response names another content type (a server that doesn't know + * `.wasm`), this reads the whole body and compiles the bytes instead: + * slower to start, the same module. A response that isn't `ok` is refused + * either way, as streaming refuses it. + * + * @param {Response | PromiseLike} source + */ +export async function instantiateStreaming(source) { + const response = await source; + if (!response.ok) { + throw new TypeError(`comline: the module's response is HTTP ${response.status}`); + } + if (typeof WebAssembly.instantiateStreaming === "function" && isWasm(response)) { + const { instance } = await WebAssembly.instantiateStreaming(response, {}); + return new Comline(instance.exports); + } + return instantiate(await response.arrayBuffer()); +} + +/** Whether a response's content type is `application/wasm`, the one + * `WebAssembly.instantiateStreaming` takes (parameters aside). */ +function isWasm(response) { + const type = response.headers.get("content-type") ?? ""; + return type.split(";")[0].trim().toLowerCase() === "application/wasm"; +} + +// ── sizing ───────────────────────────────────────────────────────────────── + +// `jsonrpc@1`'s fixed text, as `src/framing.rs` in `comline-runtime` +// writes it. +const JSONRPC_REQUEST_HEAD = '{"jsonrpc":"2.0","method":"'.length; +const JSONRPC_REQUEST_PARAMS = '","params":'.length; +const JSONRPC_ID = ',"id":'.length; +const JSONRPC_CLOSE = "}".length; +const JSONRPC_NULL = "null".length; +const JSONRPC_RESULT_HEAD = '{"jsonrpc":"2.0","result":'.length; +const JSONRPC_ERROR_HEAD = '{"jsonrpc":"2.0","error":{"code":'.length; +const JSONRPC_ERROR_DATA = ',"message":"application error","data":'.length; +const JSONRPC_ERROR_ID = '},"id":'.length; +const JSONRPC_FAULT_HEAD = + '{"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error"},"id":'.length; +/** The most digits an id takes: `u64::MAX`, and an error id's `u16::MAX`. */ +const U64_DIGITS = 20; +const U16_DIGITS = 5; +/** The most bytes `jsonrpc@1` writes for one byte of a name: `\u00XX`. */ +const ESCAPE_MAX = 6; +/** The most bytes `datagram@2`'s protocol varint takes (`u16::MAX`). */ +const VARINT_MAX = 3; + +const isU16 = (n) => Number.isInteger(n) && n >= 0 && n <= 0xffff; + +/** The bytes the LEB128 varint of a `u16` takes. */ +const varintLen = (n) => (n < 0x80 ? 1 : n < 0x4000 ? 2 : 3); + +/** A length argument: a non-negative safe integer. */ +function lengthOf(value, what) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`comline: ${what} must be a non-negative integer`); + } + return value; +} + +/** The bytes a name takes in a `jsonrpc@1` frame, between its quotes: its + * UTF-8, with `"`, `\` and the control characters escaped as serde_json + * escapes them (`put_method` in `comline-runtime`'s `src/framing.rs`). */ +function escapedLen(name) { + if (name instanceof Uint8Array) { + let len = name.length; + for (let i = 0; i < name.length; i++) len += escapeExtra(name[i]); + return len; + } + if (typeof name !== "string") throw new TypeError("expected a string or a Uint8Array"); + // UTF-8 as `TextEncoder` writes it, which is what `encodeRequest` sends: + // a lone surrogate becomes U+FFFD, 3 bytes. + let len = 0; + for (let i = 0; i < name.length; i++) { + const c = name.charCodeAt(i); + if (c < 0x80) { + len += 1 + escapeExtra(c); + } else if (c < 0x800) { + len += 2; + } else if (c >= 0xd800 && c <= 0xdbff && i + 1 < name.length) { + const next = name.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + len += 4; + i++; + } else { + len += 3; + } + } else { + len += 3; + } + } + return len; +} + +/** The bytes an escape adds to a byte of a name. */ +function escapeExtra(b) { + // `\"`, `\\`, `\n`, `\r`, `\t`, `\b`, `\f`. + if (b === 0x22 || b === 0x5c || b === 0x0a || b === 0x0d || b === 0x09 || b === 0x08 || b === 0x0c) { + return 1; + } + // `\u00XX`. + if (b < 0x20) return 5; + return 0; +} + +/** The decimal digits of an id, wrapped to a `u64` as the encodes wrap it; + * 20 when it isn't given. */ +const idDigits = (id) => (id == null ? U64_DIGITS : u64(id).toString().length); + +/** + * The most bytes an encode can write (`*written`, a stream's prefix + * included), so a binding can size `cap` and never retry. Pure: it needs no + * instance. + * + * It is never smaller than what the ABI writes, and it is exactly that when + * everything the frame holds is given: + * + * - `datagram@2`: `protocol`. Without it, the varint's most (3 bytes). + * - `jsonrpc@1`: `name` (escaped exactly as the framing escapes it), a + * request's `expectsReply` and `requestId` (the id it will be given, when + * the binding knows it), a reply's `requestId`, and an error's `errorId`. + * Without them: `nameLen` bytes at the most a byte can take escaped + * (`\u00XX`, 6), 20 digits for an id (`u64::MAX`) and 5 for an error id + * (`u16::MAX`). A request whose `expectsReply` isn't given is counted + * with its `id`. + * + * A frame over the connection's `maxFrame` is `ERR_FRAME_TOO_LARGE` at any + * `cap`; this doesn't look at the limit. + * + * @param {import("./comline.d.mts").EncodeBoundArgs} args + */ +export function encodeBound(args) { + const { framing, kind, envelope } = args; + const bodyLen = lengthOf(args.bodyLen, "bodyLen"); + const prefix = args.stream ? PREFIX_LEN : 0; + if (kind !== "request" && kind !== "reply") { + throw new TypeError('comline: kind must be "request" or "reply"'); + } + if ( + kind === "reply" && + envelope !== Envelope.OK && envelope !== Envelope.ERR && envelope !== Envelope.FAULT + ) { + throw new TypeError("comline: a reply's envelope must be an Envelope"); + } + + if (framing === "datagram@2") { + // Request: [0][protocol varint][call_id u16][request_id u64][params] + // Reply: [1][protocol varint][request_id u64][envelope] + // Envelope: [0][payload] | [1][error id u16][body] | [2] + const varint = isU16(args.protocol) ? varintLen(args.protocol) : VARINT_MAX; + if (kind === "request") return prefix + 1 + varint + 2 + 8 + bodyLen; + const sealed = + envelope === Envelope.OK ? 1 + bodyLen : envelope === Envelope.ERR ? 3 + bodyLen : 1; + return prefix + 1 + varint + 8 + sealed; + } + + if (framing === "jsonrpc@1") { + // An empty body is written as `null`. + const body = bodyLen === 0 ? JSONRPC_NULL : bodyLen; + if (kind === "request") { + const name = + args.name != null + ? escapedLen(args.name) + : lengthOf(args.nameLen, "nameLen (or name)") * ESCAPE_MAX; + // A one-way call is a notification, with no `id`. + const oneWay = args.expectsReply === false || args.expectsReply === 0; + const id = oneWay ? 0 : JSONRPC_ID + idDigits(args.requestId); + return ( + prefix + JSONRPC_REQUEST_HEAD + name + JSONRPC_REQUEST_PARAMS + body + id + JSONRPC_CLOSE + ); + } + const id = idDigits(args.requestId); + if (envelope === Envelope.OK) { + return prefix + JSONRPC_RESULT_HEAD + body + JSONRPC_ID + id + JSONRPC_CLOSE; + } + if (envelope === Envelope.ERR) { + const code = isU16(args.errorId) ? String(args.errorId).length : U16_DIGITS; + return ( + prefix + JSONRPC_ERROR_HEAD + code + JSONRPC_ERROR_DATA + body + JSONRPC_ERROR_ID + id + + JSONRPC_CLOSE + ); + } + return prefix + JSONRPC_FAULT_HEAD + id + JSONRPC_CLOSE; + } + + throw new TypeError(`comline: no encode bound for framing ${String(framing)}`); +} + +// ── the library ──────────────────────────────────────────────────────────── + +/** The glue's own errors, which are never a trap. */ +const glueErrors = new WeakSet(); + +/** A trap: the module stopped mid-call. A `RangeError` from inside an + * export is the engine's stack exhaustion, which stops the module just as + * hard. Typed-array access in the `try`s that ask this stays inside the + * regions, so it can't raise one. */ +const isTrap = (e) => + e instanceof WebAssembly.RuntimeError || (e instanceof RangeError && !glueErrors.has(e)); + /** The library: its free functions, and `connectionNew`. */ export class Comline { #x; + #dead = false; + #version; + // The regions: 8-aligned addresses, and how many bytes from there are + // the glue's (0 before the first reserve). + #inPtr = 0; + #inCap = 0; + #outPtr = 0; + #outCap = 0; + // Views of memory as it was when last seen. + #buffer = null; + #u8 = null; + #dv = null; /** Prefer `instantiate`. Checks the version and the layout, as it says. */ constructor(exports) { this.#x = exports; - const version = this.abiVersion(); + const version = exports.comline_abi_version() >>> 0; if (version !== ((ABI_MAJOR << 16) | ABI_MINOR)) { throw new Error( `comline: the module is ABI ${version >>> 16}.${version & 0xffff}, this glue ${ABI_MAJOR}.${ABI_MINOR}`, ); } + this.#version = version; this.#checkLayout(); } @@ -166,29 +446,53 @@ export class Comline { } } + /** Whether a trap killed this instance: every call now returns + * `ERR_POISONED` without entering the module. */ + get poisoned() { + return this.#dead; + } + // ── memory ────────────────────────────────────────────────────────────── - /** - * Reserve `inLen` bytes of `in` and `outLen` of `out`, 8-aligned, and - * return their addresses with fresh views (a reserve may grow memory, - * which detaches the old ones). - */ - #regions(inLen, outLen) { - const x = this.#x; - const at = (reserve, len) => { - const ptr = reserve(len + 8) >>> 0; - if (ptr === 0) throw new RangeError("comline: wasm memory can't grow"); - return align8(ptr); - }; - const inPtr = at(x.comline_wasm_reserve_in, inLen); - const outPtr = at(x.comline_wasm_reserve_out, outLen); - const buffer = x.memory.buffer; - return { inPtr, outPtr, u8: new Uint8Array(buffer), dv: new DataView(buffer) }; + /** Make sure `in` holds `inLen` bytes and `out` holds `outLen`, reserving + * only a region that is too small, and take the views again if memory + * grew. */ + #room(inLen, outLen) { + if (inLen > this.#inCap) { + const [ptr, cap] = this.#reserve(this.#x.comline_wasm_reserve_in, inLen, this.#inCap); + this.#inPtr = ptr; + this.#inCap = cap; + } + if (outLen > this.#outCap) { + const [ptr, cap] = this.#reserve(this.#x.comline_wasm_reserve_out, outLen, this.#outCap); + this.#outPtr = ptr; + this.#outCap = cap; + } + this.#sync(); } - /** A view of memory as it is now. */ - #view() { - return new DataView(this.#x.memory.buffer); + /** Reserve room for at least `len` bytes, 8-aligned, growing by half at + * least, so a slowly growing size doesn't reserve on every call. */ + #reserve(reserve, len, cap) { + const want = Math.max(len, cap + (cap >>> 1), REGION_MIN); + const ptr = reserve(want + 8) >>> 0; + if (ptr === 0) { + const e = new RangeError("comline: wasm memory can't grow"); + glueErrors.add(e); + throw e; + } + const aligned = align8(ptr); + return [aligned, want + 8 - (aligned - ptr)]; + } + + /** Take the views again if memory grew since they were taken. */ + #sync() { + const buffer = this.#x.memory.buffer; + if (buffer !== this.#buffer) { + this.#buffer = buffer; + this.#u8 = new Uint8Array(buffer); + this.#dv = new DataView(buffer); + } } /** Lay `parts` out in `in` from `base`, each 8-aligned; their addresses. */ @@ -207,30 +511,50 @@ export class Comline { return parts.reduce((n, p) => n + align8(p.length), 0); } + /** A call that threw: a trap kills the instance and gives `result`; + * anything else is thrown on. */ + #fail(e, result) { + if (!isTrap(e)) throw e; + this.#dead = true; + return result; + } + // ── free functions ────────────────────────────────────────────────────── - /** `comline_abi_version`: `(major << 16) | minor`. */ + /** `comline_abi_version`: `(major << 16) | minor`, as read at load. */ abiVersion() { - return this.#x.comline_abi_version() >>> 0; + return this.#version; } /** `comline_supports`: whether the named framing or handshake works on a - * connection. */ + * connection. False on a poisoned instance. */ supports(name) { + if (this.#dead) return false; const n = bytesOf(name); - const { inPtr, u8 } = this.#regions(n.length, 0); - u8.set(n, inPtr); - return this.#x.comline_supports(inPtr, n.length) === 1; + try { + this.#room(n.length, 0); + this.#u8.set(n, this.#inPtr); + return this.#x.comline_supports(this.#inPtr, n.length) === 1; + } catch (e) { + return this.#fail(e, false); + } } /** `comline_name_hash`: `{ status, hash }`, `hash` a BigInt. */ nameHash(name) { + if (this.#dead) return { status: POISONED }; const n = bytesOf(name); - const { inPtr, outPtr, u8 } = this.#regions(n.length, 8); - u8.set(n, inPtr); - const status = this.#x.comline_name_hash(inPtr, n.length, outPtr); - const hash = status === Status.OK ? this.#view().getBigUint64(outPtr, true) : undefined; - return { status, hash }; + try { + this.#room(n.length, 8); + const outPtr = this.#outPtr; + this.#u8.set(n, this.#inPtr); + const status = this.#x.comline_name_hash(this.#inPtr, n.length, outPtr); + this.#sync(); + const hash = status === Status.OK ? this.#dv.getBigUint64(outPtr, true) : undefined; + return { status, hash }; + } catch (e) { + return this.#fail(e, { status: POISONED }); + } } /** @@ -238,15 +562,22 @@ export class Comline { * the size needed on `BUFFER_TOO_SMALL`; `bytes` is there on `OK`. */ handshakeEncode(irHash, wireFormat, framing, capabilities, cap) { + if (this.#dead) return { status: POISONED, written: 0 }; const w = bytesOf(wireFormat); const f = bytesOf(framing); - const { inPtr, outPtr, u8 } = this.#regions(Comline.#size([w, f]), 8 + cap); - const [wPtr, fPtr] = Comline.#place(u8, inPtr, [w, f]); - const status = this.#x.comline_handshake_encode( - BigInt.asUintN(64, BigInt(irHash)), wPtr, w.length, fPtr, f.length, - capabilities >>> 0, outPtr + 8, cap, outPtr, - ); - return this.#written(status, outPtr, outPtr + 8); + const ir = u64(irHash); + try { + this.#room(Comline.#size([w, f]), 8 + cap); + const outPtr = this.#outPtr; + const [wPtr, fPtr] = Comline.#place(this.#u8, this.#inPtr, [w, f]); + this.#dv.setUint32(outPtr, 0, true); + const status = this.#x.comline_handshake_encode( + ir, wPtr, w.length, fPtr, f.length, capabilities >>> 0, outPtr + 8, cap, outPtr, + ); + return this.#written(status, outPtr, outPtr + 8); + } catch (e) { + return this.#fail(e, { status: POISONED, written: 0 }); + } } /** @@ -255,28 +586,34 @@ export class Comline { * the ABI didn't write it. */ handshakeCheck(ours, peer) { + if (this.#dead) return { status: POISONED, peer: null }; const o = bytesOf(ours); const p = bytesOf(peer); - const { inPtr, outPtr, u8, dv: before } = this.#regions(Comline.#size([o, p]), HANDSHAKE_SIZE); - let dv = before; - const [oPtr, pPtr] = Comline.#place(u8, inPtr, [o, p]); - // A sentinel in `_reserved`, which a written frame holds as 0. - u8.fill(0, outPtr, outPtr + HANDSHAKE_SIZE); - dv.setUint32(outPtr + 28, 0xdead, true); - const status = this.#x.comline_handshake_check(oPtr, o.length, pPtr, p.length, outPtr); - dv = this.#view(); - const written = dv.getUint32(outPtr + 28, true) !== 0xdead; - return { - status, - peer: written - ? { - irHash: dv.getBigUint64(outPtr, true), - wireFormat: dv.getBigUint64(outPtr + 8, true), - framing: dv.getBigUint64(outPtr + 16, true), - capabilities: dv.getUint32(outPtr + 24, true), - } - : null, - }; + try { + this.#room(Comline.#size([o, p]), HANDSHAKE_SIZE); + const outPtr = this.#outPtr; + const [oPtr, pPtr] = Comline.#place(this.#u8, this.#inPtr, [o, p]); + // A sentinel in `_reserved`, which a written frame holds as 0. + this.#u8.fill(0, outPtr, outPtr + HANDSHAKE_SIZE); + this.#dv.setUint32(outPtr + 28, 0xdead, true); + const status = this.#x.comline_handshake_check(oPtr, o.length, pPtr, p.length, outPtr); + this.#sync(); + const dv = this.#dv; + const written = dv.getUint32(outPtr + 28, true) !== 0xdead; + return { + status, + peer: written + ? { + irHash: dv.getBigUint64(outPtr, true), + wireFormat: dv.getBigUint64(outPtr + 8, true), + framing: dv.getBigUint64(outPtr + 16, true), + capabilities: dv.getUint32(outPtr + 24, true), + } + : null, + }; + } catch (e) { + return this.#fail(e, { status: POISONED, peer: null }); + } } /** @@ -284,11 +621,20 @@ export class Comline { * `handshakeEncode`. `spelled` is a `callName` span's bytes. */ methodNameUnescape(spelled, cap) { + if (this.#dead) return { status: POISONED, written: 0 }; const s = bytesOf(spelled); - const { inPtr, outPtr, u8 } = this.#regions(s.length, 8 + cap); - u8.set(s, inPtr); - const status = this.#x.comline_method_name_unescape(inPtr, s.length, outPtr + 8, cap, outPtr); - return this.#written(status, outPtr, outPtr + 8); + try { + this.#room(s.length, 8 + cap); + const outPtr = this.#outPtr; + this.#u8.set(s, this.#inPtr); + this.#dv.setUint32(outPtr, 0, true); + const status = this.#x.comline_method_name_unescape( + this.#inPtr, s.length, outPtr + 8, cap, outPtr, + ); + return this.#written(status, outPtr, outPtr + 8); + } catch (e) { + return this.#fail(e, { status: POISONED, written: 0 }); + } } /** @@ -299,32 +645,42 @@ export class Comline { * @param {import("./comline.d.mts").Config} config */ connectionNew(config) { + if (this.#dead) return { status: POISONED, connection: null }; const w = bytesOf(config.wireFormat ?? ""); const f = bytesOf(config.framing ?? ""); - const { inPtr, outPtr, u8, dv } = this.#regions(CONFIG_SIZE + Comline.#size([w, f]), 4); - const [wPtr, fPtr] = Comline.#place(u8, inPtr + CONFIG_SIZE, [w, f]); - const c = inPtr; - u8.fill(0, c, c + CONFIG_SIZE); - dv.setBigUint64(c + CONFIG.irHash, BigInt.asUintN(64, BigInt(config.irHash ?? 0)), true); - dv.setUint32(c + CONFIG.wireFormat, wPtr, true); - dv.setUint32(c + CONFIG.wireFormatLen, w.length, true); - dv.setUint32(c + CONFIG.framing, fPtr, true); - dv.setUint32(c + CONFIG.framingLen, f.length, true); - dv.setBigUint64(c + CONFIG.maxFrame, BigInt.asUintN(64, BigInt(config.maxFrame ?? 0)), true); - dv.setUint32(c + CONFIG.capabilities, (config.capabilities ?? 0) >>> 0, true); - dv.setUint32(c + CONFIG.maxWaiting, (config.maxWaiting ?? 0) >>> 0, true); - dv.setUint8(c + CONFIG.handshake, config.handshake ?? 0); - dv.setUint8(c + CONFIG.stream, config.stream ? 1 : 0); - dv.setUint8(c + CONFIG.unordered, config.unordered ? 1 : 0); - dv.setUint32(outPtr, 0, true); - const status = this.#x.comline_connection_new(c, outPtr); - // `new` allocates the handle, which may grow memory: read through a - // fresh view. - const handle = this.#view().getUint32(outPtr, true); - return { - status, - connection: status === Status.OK ? new Connection(this, this.#x, handle) : null, - }; + const irHash = u64(config.irHash ?? 0); + const maxFrame = u64(config.maxFrame ?? 0); + try { + this.#room(CONFIG_SIZE + Comline.#size([w, f]), 4); + const c = this.#inPtr; + const outPtr = this.#outPtr; + const u8 = this.#u8; + const dv = this.#dv; + const [wPtr, fPtr] = Comline.#place(u8, c + CONFIG_SIZE, [w, f]); + u8.fill(0, c, c + CONFIG_SIZE); + dv.setBigUint64(c + CONFIG.irHash, irHash, true); + dv.setUint32(c + CONFIG.wireFormat, wPtr, true); + dv.setUint32(c + CONFIG.wireFormatLen, w.length, true); + dv.setUint32(c + CONFIG.framing, fPtr, true); + dv.setUint32(c + CONFIG.framingLen, f.length, true); + dv.setBigUint64(c + CONFIG.maxFrame, maxFrame, true); + dv.setUint32(c + CONFIG.capabilities, (config.capabilities ?? 0) >>> 0, true); + dv.setUint32(c + CONFIG.maxWaiting, (config.maxWaiting ?? 0) >>> 0, true); + dv.setUint8(c + CONFIG.handshake, config.handshake ?? 0); + dv.setUint8(c + CONFIG.stream, config.stream ? 1 : 0); + dv.setUint8(c + CONFIG.unordered, config.unordered ? 1 : 0); + dv.setUint32(outPtr, 0, true); + const status = this.#x.comline_connection_new(c, outPtr); + // `new` allocates the handle, which may grow memory. + this.#sync(); + const handle = this.#dv.getUint32(outPtr, true); + return { + status, + connection: status === Status.OK ? new Connection(this, handle) : null, + }; + } catch (e) { + return this.#fail(e, { status: POISONED, connection: null }); + } } // ── for Connection ────────────────────────────────────────────────────── @@ -332,93 +688,156 @@ export class Comline { /** `{ status, written, bytes }` from a `*written` at `writtenPtr` and * bytes at `bytesPtr`. */ #written(status, writtenPtr, bytesPtr) { - const written = this.#view().getUint32(writtenPtr, true); + this.#sync(); + const written = this.#dv.getUint32(writtenPtr, true); const result = { status, written }; if (status === Status.OK) { - result.bytes = new Uint8Array(this.#x.memory.buffer, bytesPtr, written).slice(); + result.bytes = this.#u8.slice(bytesPtr, bytesPtr + written); } return result; } /** @internal */ _start(handle, cap) { - const { outPtr } = this.#regions(0, 8 + cap); - const status = this.#x.comline_connection_start(handle, outPtr + 8, cap, outPtr); - return this.#written(status, outPtr, outPtr + 8); + if (this.#dead) return { status: POISONED, written: 0 }; + try { + this.#room(0, 8 + cap); + const outPtr = this.#outPtr; + this.#dv.setUint32(outPtr, 0, true); + const status = this.#x.comline_connection_start(handle, outPtr + 8, cap, outPtr); + return this.#written(status, outPtr, outPtr + 8); + } catch (e) { + return this.#fail(e, { status: POISONED, written: 0 }); + } } /** @internal `watch` bytes after the event are checked untouched * (`tailIntact`), for the layout check. */ _feed(handle, input, watch = 0) { + if (this.#dead) return { status: POISONED, consumed: 0, event: zeroEvent() }; const i = bytesOf(input); - const { inPtr, outPtr, u8 } = this.#regions(i.length, 8 + EVENT_SIZE + watch); - u8.set(i, inPtr); - const evPtr = outPtr + 8; - u8.fill(0, evPtr, evPtr + EVENT_SIZE); - u8.fill(0xa5, evPtr + EVENT_SIZE, evPtr + EVENT_SIZE + watch); - const status = this.#x.comline_connection_feed(handle, inPtr, i.length, outPtr, evPtr); - const view = this.#view(); - const after = new Uint8Array(this.#x.memory.buffer, evPtr + EVENT_SIZE, watch); - return { - status, - consumed: view.getUint32(outPtr, true), - event: readEvent(view, evPtr), - ...(watch ? { tailIntact: after.every((b) => b === 0xa5) } : {}), - }; + try { + this.#room(i.length, 8 + EVENT_SIZE + watch); + const outPtr = this.#outPtr; + const evPtr = outPtr + 8; + const u8 = this.#u8; + u8.set(i, this.#inPtr); + u8.fill(0, outPtr, evPtr + EVENT_SIZE); + u8.fill(0xa5, evPtr + EVENT_SIZE, evPtr + EVENT_SIZE + watch); + const status = this.#x.comline_connection_feed(handle, this.#inPtr, i.length, outPtr, evPtr); + // `jsonrpc@1`'s decode can allocate (runtime#58), which may grow + // memory. + this.#sync(); + const dv = this.#dv; + const result = { + status, + consumed: dv.getUint32(outPtr, true), + event: readEvent(dv, evPtr), + }; + if (watch) { + const tail = evPtr + EVENT_SIZE; + result.tailIntact = this.#u8.subarray(tail, tail + watch).every((b) => b === 0xa5); + } + return result; + } catch (e) { + return this.#fail(e, { status: POISONED, consumed: 0, event: zeroEvent() }); + } } /** @internal */ _encodeRequest(handle, protocol, callId, name, expectsReply, body, cap) { + if (this.#dead) return { status: POISONED, written: 0 }; const n = bytesOf(name); const b = bytesOf(body); - const { inPtr, outPtr, u8, dv } = this.#regions(Comline.#size([n, b]), 16 + cap); - const [nPtr, bPtr] = Comline.#place(u8, inPtr, [n, b]); - dv.setBigUint64(outPtr + 8, 0n, true); const reply = typeof expectsReply === "boolean" ? (expectsReply ? 1 : 0) : expectsReply; - const status = this.#x.comline_connection_encode_request( - handle, protocol, callId, nPtr, n.length, reply, bPtr, b.length, - outPtr + 16, cap, outPtr, outPtr + 8, - ); - const result = this.#written(status, outPtr, outPtr + 16); - if (status === Status.OK) { - result.requestId = this.#view().getBigUint64(outPtr + 8, true); + try { + this.#room(Comline.#size([n, b]), 16 + cap); + const outPtr = this.#outPtr; + const [nPtr, bPtr] = Comline.#place(this.#u8, this.#inPtr, [n, b]); + this.#dv.setUint32(outPtr, 0, true); + this.#dv.setBigUint64(outPtr + 8, 0n, true); + const status = this.#x.comline_connection_encode_request( + handle, protocol, callId, nPtr, n.length, reply, bPtr, b.length, + outPtr + 16, cap, outPtr, outPtr + 8, + ); + const result = this.#written(status, outPtr, outPtr + 16); + if (status === Status.OK) { + result.requestId = this.#dv.getBigUint64(outPtr + 8, true); + } + return result; + } catch (e) { + return this.#fail(e, { status: POISONED, written: 0 }); } - return result; } /** @internal */ _encodeReply(handle, protocol, requestId, envelope, errorId, body, cap) { + if (this.#dead) return { status: POISONED, written: 0 }; const b = bytesOf(body); - const { inPtr, outPtr, u8 } = this.#regions(b.length, 8 + cap); - u8.set(b, inPtr); - const status = this.#x.comline_connection_encode_reply( - handle, protocol, BigInt.asUintN(64, BigInt(requestId)), envelope, errorId, - inPtr, b.length, outPtr + 8, cap, outPtr, - ); - return this.#written(status, outPtr, outPtr + 8); + const id = u64(requestId); + try { + this.#room(b.length, 8 + cap); + const outPtr = this.#outPtr; + this.#u8.set(b, this.#inPtr); + this.#dv.setUint32(outPtr, 0, true); + const status = this.#x.comline_connection_encode_reply( + handle, protocol, id, envelope, errorId, this.#inPtr, b.length, outPtr + 8, cap, outPtr, + ); + return this.#written(status, outPtr, outPtr + 8); + } catch (e) { + return this.#fail(e, { status: POISONED, written: 0 }); + } + } + + /** @internal */ + _abandon(handle, requestId) { + if (this.#dead) return POISONED; + const id = u64(requestId); + try { + return this.#x.comline_connection_abandon(handle, id); + } catch (e) { + return this.#fail(e, POISONED); + } } /** @internal */ _isWaiting(handle) { - const { outPtr, dv } = this.#regions(0, 1); - dv.setUint8(outPtr, 0xff); - const status = this.#x.comline_connection_is_waiting(handle, outPtr); - const waiting = this.#view().getUint8(outPtr); - return { status, waiting: status === Status.OK ? waiting === 1 : undefined }; + if (this.#dead) return { status: POISONED }; + try { + this.#room(0, 1); + const outPtr = this.#outPtr; + this.#dv.setUint8(outPtr, 0xff); + const status = this.#x.comline_connection_is_waiting(handle, outPtr); + this.#sync(); + const waiting = this.#dv.getUint8(outPtr); + return { status, waiting: status === Status.OK ? waiting === 1 : undefined }; + } catch (e) { + return this.#fail(e, { status: POISONED }); + } + } + + /** @internal On a poisoned instance there's nothing to free: the module + * goes with the instance. */ + _free(handle) { + if (this.#dead) return; + try { + this.#x.comline_connection_free(handle); + } catch (e) { + this.#fail(e, undefined); + } } } /** One end of a connection: a `comline_connection *`. Free it with - * `free()`; nothing frees it for you. */ + * `free()`; nothing frees it for you. Once its instance is poisoned, every + * call returns `ERR_POISONED`. */ export class Connection { #lib; - #x; #handle; /** @internal Made by `Comline.connectionNew`. */ - constructor(lib, exports, handle) { + constructor(lib, handle) { this.#lib = lib; - this.#x = exports; this.#handle = handle; } @@ -455,7 +874,7 @@ export class Connection { /** `comline_connection_abandon`: the status. */ abandon(requestId) { - return this.#x.comline_connection_abandon(this.#live(), BigInt.asUintN(64, BigInt(requestId))); + return this.#lib._abandon(this.#live(), requestId); } /** `comline_connection_is_waiting`: `{ status, waiting }`. */ @@ -464,10 +883,11 @@ export class Connection { } /** `comline_connection_free`. The connection is unusable afterwards; - * freeing twice is a no-op here. */ + * freeing twice is a no-op here, and so is freeing on a poisoned + * instance. */ free() { if (this.#handle !== 0) { - this.#x.comline_connection_free(this.#handle); + this.#lib._free(this.#handle); this.#handle = 0; } } @@ -499,4 +919,9 @@ function readEvent(dv, ptr) { }; } +/** A zeroed `comline_event`, as a poisoned `feed` gives. */ +function zeroEvent() { + return readEvent(new DataView(new ArrayBuffer(EVENT_SIZE)), 0); +} + const big = (_, v) => (typeof v === "bigint" ? v.toString() : v); diff --git a/runtime/ffi/js/test/env.mjs b/runtime/ffi/js/test/env.mjs new file mode 100644 index 0000000..907bc11 --- /dev/null +++ b/runtime/ffi/js/test/env.mjs @@ -0,0 +1,23 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Where the Node tests find what they run against. Each defaults to its +// place under `CARGO_TARGET_DIR` (or the repository's `target`): +// +// - `COMLINE_WASM`: the wasm32 build, `--profile wasm`; +// - `COMLINE_WASM_TEST_HOOKS`: the same with `--features test-hooks`, built +// into `/test-hooks` so it doesn't replace the other; +// - `COMLINE_VECTORS`: the vectors `examples/export_vectors.rs` exports. + +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const TARGET = process.env.CARGO_TARGET_DIR || join(ROOT, "target"); +const BUILD = join("wasm32-unknown-unknown", "wasm", "comline_runtime_ffi.wasm"); + +export const WASM = process.env.COMLINE_WASM || join(TARGET, BUILD); +export const WASM_TEST_HOOKS = + process.env.COMLINE_WASM_TEST_HOOKS || join(TARGET, "test-hooks", BUILD); +export const VECTORS = process.env.COMLINE_VECTORS || join(TARGET, "ffi-vectors"); diff --git a/runtime/ffi/js/test/poison.test.mjs b/runtime/ffi/js/test/poison.test.mjs new file mode 100644 index 0000000..e4af17b --- /dev/null +++ b/runtime/ffi/js/test/poison.test.mjs @@ -0,0 +1,192 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// The wasm32 poisoning rule: a trap poisons the whole instance. Against the +// `test-hooks` build (`env.mjs`), whose `comline_test_panic_next` makes the +// next call on a handle panic inside its guard, as `tests/rules.rs` does +// natively. On wasm32 that panic is a trap the guard can't catch, so the +// glue must mark the instance dead: the call that trapped and every later +// call on the instance and on each of its connections return +// `ERR_POISONED`, without entering the module, while a fresh instance +// works. +// +// node --test runtime/ffi/js/test/poison.test.mjs + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { Comline, Envelope, EventKind, Handshake, Status, instantiateSync } from "../comline.mjs"; +import { WASM_TEST_HOOKS } from "./env.mjs"; + +const bytes = readFileSync(WASM_TEST_HOOKS); +const module = new WebAssembly.Module(bytes); +const POISONED = Status.ERR_POISONED; + +/** A request frame for `datagram@2`: protocol 0, call 1, request id 7. */ +const REQUEST = Uint8Array.of(0, 0, 1, 0, 7, 0, 0, 0, 0, 0, 0, 0); + +/** + * An instance whose exports are wrapped, to count the calls that enter the + * module and to replace one where a test says so (`swap[name]`). The glue + * sees an ordinary exports object. + */ +function counted() { + const raw = new WebAssembly.Instance(module, {}).exports; + const counter = { calls: 0, swap: {} }; + const exports = {}; + for (const [name, value] of Object.entries(raw)) { + exports[name] = + typeof value === "function" + ? (...args) => { + counter.calls++; + return (counter.swap[name] ?? value)(...args); + } + : value; + } + return { raw, counter, comline: new Comline(exports) }; +} + +const setup = { framing: "datagram@2", handshake: Handshake.NONE, maxWaiting: 4 }; + +function open(comline) { + const { status, connection } = comline.connectionNew(setup); + assert.equal(status, Status.OK); + return connection; +} + +/** Every call there is, on `comline` and on `conns`, each of which must say + * poisoned. */ +function everyCallPoisoned(comline, conns) { + for (const conn of conns) { + assert.deepEqual(conn.start(64), { status: POISONED, written: 0 }); + const fed = conn.feed(REQUEST); + assert.equal(fed.status, POISONED); + assert.equal(fed.consumed, 0); + assert.equal(fed.event.kind, 0); + assert.equal(fed.event.requestId, 0n); + assert.deepEqual(conn.encodeRequest(0, 1, "", true, "body", 64), { status: POISONED, written: 0 }); + assert.deepEqual(conn.encodeReply(0, 7n, Envelope.OK, 0, "body", 64), { + status: POISONED, + written: 0, + }); + assert.equal(conn.abandon(7n), POISONED); + assert.deepEqual(conn.isWaiting(), { status: POISONED }); + } + assert.deepEqual(comline.nameHash("json@1"), { status: POISONED }); + assert.deepEqual(comline.handshakeEncode(1n, "json@1", "datagram@2", 0, 64), { + status: POISONED, + written: 0, + }); + assert.deepEqual(comline.handshakeCheck(new Uint8Array(31), new Uint8Array(31)), { + status: POISONED, + peer: null, + }); + assert.deepEqual(comline.methodNameUnescape("a", 8), { status: POISONED, written: 0 }); + assert.deepEqual(comline.connectionNew(setup), { status: POISONED, connection: null }); + assert.equal(comline.supports("datagram@2"), false); + assert.equal(comline.poisoned, true); +} + +test("the hook's panic is a trap on wasm32, not ERR_POISONED", () => { + const x = new WebAssembly.Instance(module, {}).exports; + assert.equal(typeof x.comline_test_panic_next, "function"); + // A raw `comline_connection_abandon` on a handle the glue made: the + // module traps instead of returning a status. + const comline = new Comline(x); + const conn = open(comline); + x.comline_test_panic_next(conn.handle); + assert.throws(() => x.comline_connection_abandon(conn.handle, 0n), WebAssembly.RuntimeError); +}); + +test("a trap poisons the instance and every connection on it", () => { + const { raw, counter, comline } = counted(); + const a = open(comline); + const b = open(comline); + // Both work before the trap. + const sent = a.encodeRequest(0, 1, "", true, "x", 64); + assert.equal(sent.status, Status.OK); + assert.equal(b.feed(REQUEST).event.kind, EventKind.REQUEST); + assert.equal(comline.poisoned, false); + + raw.comline_test_panic_next(a.handle); + const trapped = a.feed(REQUEST); + assert.equal(trapped.status, POISONED, "the call that trapped"); + assert.equal(comline.poisoned, true); + + // Nothing after it enters the module. + const before = counter.calls; + everyCallPoisoned(comline, [a, b]); + a.free(); + b.free(); + assert.equal(counter.calls, before, "a poisoned instance was entered"); + // Freed connections are still a caller's mistake, as before. + assert.throws(() => a.feed(REQUEST), /freed/); + + // A fresh instance works. + const fresh = instantiateSync(bytes); + const conn = open(fresh); + const request = conn.encodeRequest(0, 1, "", true, "x", 64); + assert.equal(request.status, Status.OK); + const reply = conn.encodeReply(0, request.requestId, Envelope.OK, 0, "y", 64); + assert.equal(reply.status, Status.OK); + const fed = conn.feed(reply.bytes); + assert.equal(fed.status, Status.OK); + assert.equal(fed.event.kind, EventKind.REPLY); + assert.equal(fed.event.requestId, request.requestId); + conn.free(); + assert.equal(fresh.poisoned, false); +}); + +test("every connection call that traps poisons", () => { + const calls = { + start: (c) => c.start(64), + feed: (c) => c.feed(REQUEST), + encodeRequest: (c) => c.encodeRequest(0, 1, "", true, "x", 64), + encodeReply: (c) => c.encodeReply(0, 7n, Envelope.OK, 0, "x", 64), + abandon: (c) => ({ status: c.abandon(7n) }), + isWaiting: (c) => c.isWaiting(), + }; + for (const [name, call] of Object.entries(calls)) { + const { raw, comline } = counted(); + const a = open(comline); + const b = open(comline); + raw.comline_test_panic_next(a.handle); + assert.equal(call(a).status, POISONED, `${name} that trapped`); + everyCallPoisoned(comline, [a, b]); + } +}); + +test("a RangeError from inside the module is a trap too", () => { + // The engine reports stack exhaustion in wasm as a `RangeError`, which + // stops the module mid-call just as a trap does. + const { counter, comline } = counted(); + const conn = open(comline); + counter.swap.comline_name_hash = () => { + throw new RangeError("Maximum call stack size exceeded"); + }; + assert.deepEqual(comline.nameHash("json@1"), { status: POISONED }); + everyCallPoisoned(comline, [conn]); +}); + +test("other exceptions are thrown as before, and poison nothing", () => { + const { counter, comline } = counted(); + const conn = open(comline); + // Caught in the glue, before the module. + assert.throws(() => conn.encodeRequest(0, 1, "", true, 42, 64), TypeError); + assert.throws(() => comline.nameHash({}), TypeError); + assert.throws(() => conn.encodeReply(0, 1.5, Envelope.OK, 0, "x", 64), RangeError); + // Thrown by the engine at the boundary, as it converts an argument: a + // BigInt where a `u16` goes. + assert.throws(() => conn.encodeRequest(1n, 1, "", true, "x", 64), TypeError); + // Memory that can't grow: the module is as it was. + counter.swap.comline_wasm_reserve_in = () => 0; + assert.throws(() => comline.nameHash("x".repeat(1 << 20)), /can't grow/); + delete counter.swap.comline_wasm_reserve_in; + assert.equal(comline.poisoned, false); + // Still working. + assert.equal(conn.feed(REQUEST).status, Status.OK); + assert.equal(comline.nameHash("json@1").status, Status.OK); + conn.free(); +}); diff --git a/runtime/ffi/js/test/regions.test.mjs b/runtime/ffi/js/test/regions.test.mjs new file mode 100644 index 0000000..46de5b1 --- /dev/null +++ b/runtime/ffi/js/test/regions.test.mjs @@ -0,0 +1,162 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// The memory regions: the module keeps its side of the promise the glue's +// cached regions rest on (`mod wasm` in `src/lib.rs`), and the glue then +// enters the module once per call. +// +// node --test runtime/ffi/js/test/regions.test.mjs + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { Comline, Envelope, EventKind, Handshake, Status } from "../comline.mjs"; +import { WASM } from "./env.mjs"; + +const module = new WebAssembly.Module(readFileSync(WASM)); +const utf8 = new TextEncoder(); + +test("a region moves or changes only when a reserve asks it for more", () => { + const x = new WebAssembly.Instance(module, {}).exports; + const IN = 4096; + const OUT = 1 << 16; + const inPtr = x.comline_wasm_reserve_in(IN) >>> 0; + const outPtr = x.comline_wasm_reserve_out(OUT) >>> 0; + assert.ok(inPtr !== 0 && outPtr !== 0); + const pattern = (i) => (i * 131 + 7) & 0xff; + new Uint8Array(x.memory.buffer, inPtr, IN).forEach((_, i, a) => (a[i] = pattern(i))); + const pagesBefore = x.memory.buffer.byteLength; + + // Work that allocates, frees and grows memory, all through `out`: inputs + // in its first half, outputs in its second. + const put = (at, bytes) => { + new Uint8Array(x.memory.buffer).set(bytes, outPtr + at); + return outPtr + at; + }; + const OUTPUTS = outPtr + OUT / 2; + const handleAt = OUTPUTS; + const newConn = (framing, maxWaiting) => { + const f = utf8.encode(framing); + const fPtr = put(64, f); + const cfg = new DataView(x.memory.buffer, put(0, new Uint8Array(48)), 48); + cfg.setUint32(16, fPtr, true); + cfg.setUint32(20, f.length, true); + cfg.setUint32(36, maxWaiting, true); + cfg.setUint8(40, Handshake.NONE); + assert.equal(x.comline_connection_new(cfg.byteOffset, handleAt), Status.OK); + return new DataView(x.memory.buffer).getUint32(handleAt, true); + }; + for (let round = 0; round < 50; round++) { + const conn = newConn("jsonrpc@1", 16); + // `jsonrpc@1`'s decode allocates (runtime#58). + const frame = utf8.encode(`{"jsonrpc":"2.0","method":"a\\u0062","params":[[[${round}]]],"id":${round}}`); + const status = x.comline_connection_feed(conn, put(128, frame), frame.length, OUTPUTS, OUTPUTS + 8); + assert.equal(status, Status.OK); + x.comline_connection_free(conn); + // A reserve for no more than a region holds hands back the same one. + assert.equal(x.comline_wasm_reserve_out(OUT) >>> 0, outPtr); + assert.equal(x.comline_wasm_reserve_out(1) >>> 0, outPtr); + } + // A connection big enough to grow memory. + const big = newConn("datagram@2", 4_000_000); + assert.ok(x.memory.buffer.byteLength > pagesBefore, "memory grew"); + x.comline_connection_free(big); + + // `in` is where it was, holding what it held. + assert.equal(x.comline_wasm_reserve_in(IN) >>> 0, inPtr); + assert.equal(x.comline_wasm_reserve_in(0) >>> 0, inPtr); + const now = new Uint8Array(x.memory.buffer, inPtr, IN); + assert.ok(now.every((b, i) => b === pattern(i)), "the in region changed"); + + // Asking for more may move it, and keeps what it held. + const moved = x.comline_wasm_reserve_in(IN * 64) >>> 0; + assert.notEqual(moved, 0); + const kept = new Uint8Array(x.memory.buffer, moved, IN); + assert.ok(kept.every((b, i) => b === pattern(i)), "growing lost the contents"); + assert.equal(x.comline_wasm_reserve_out(OUT) >>> 0, outPtr, "growing in moved out"); +}); + +/** The glue over exports that count every call into the module, and every + * reserve. */ +function counted() { + const raw = new WebAssembly.Instance(module, {}).exports; + const count = { calls: 0, reserves: 0 }; + const exports = {}; + for (const [name, value] of Object.entries(raw)) { + exports[name] = + typeof value === "function" + ? (...args) => { + count.calls++; + if (name.startsWith("comline_wasm_reserve_")) count.reserves++; + return value(...args); + } + : value; + } + return { count, comline: new Comline(exports) }; +} + +test("a call that fits the regions is one crossing", () => { + const { count, comline } = counted(); + const { connection: conn } = comline.connectionNew({ + framing: "jsonrpc@1", + handshake: Handshake.NONE, + maxWaiting: 64, + }); + const once = (what, call) => { + const before = { ...count }; + const result = call(); + assert.equal(count.calls - before.calls, 1, `${what}: crossings`); + assert.equal(count.reserves, before.reserves, `${what}: reserved`); + return result; + }; + for (let i = 0; i < 20; i++) { + const sent = once("encodeRequest", () => conn.encodeRequest(0, 0, "app.Chat/send", true, "[1]", 256)); + assert.equal(sent.status, Status.OK); + const reply = `{"jsonrpc":"2.0","result":${i},"id":${sent.requestId}}`; + const fed = once("feed", () => conn.feed(reply)); + assert.equal(fed.event.kind, EventKind.REPLY); + once("encodeReply", () => conn.encodeReply(0, 9n, Envelope.OK, 0, "1", 256)); + once("isWaiting", () => conn.isWaiting()); + once("abandon", () => conn.abandon(99n)); + once("nameHash", () => comline.nameHash("json@1")); + once("supports", () => comline.supports("jsonrpc@1")); + once("handshakeEncode", () => comline.handshakeEncode(1n, "json@1", "jsonrpc@1", 0, 64)); + } + + // A call that needs more reserves once, then fits. + const body = "1".repeat(100_000); + const before = count.reserves; + const big = conn.encodeReply(0, 9n, Envelope.OK, 0, body, 100_100); + assert.equal(big.status, Status.OK); + assert.equal(count.reserves - before, 2, "in and out, once each"); + once("encodeReply, large again", () => conn.encodeReply(0, 9n, Envelope.OK, 0, body, 100_100)); + conn.free(); +}); + +test("memory growing between calls doesn't disturb the glue", () => { + const { comline } = counted(); + const { connection: conn } = comline.connectionNew({ + framing: "datagram@2", + handshake: Handshake.NONE, + maxWaiting: 4, + }); + const first = conn.encodeRequest(3, 4, "", true, "abc", 64); + // Grow memory through another connection's allocation. + const { status, connection: big } = comline.connectionNew({ + framing: "datagram@2", + handshake: Handshake.NONE, + maxWaiting: 4_000_000, + }); + assert.equal(status, Status.OK); + const second = conn.encodeRequest(3, 4, "", true, "abc", 64); + assert.equal(second.status, Status.OK); + assert.equal(second.requestId, first.requestId + 1n); + assert.deepEqual(second.bytes.subarray(12), first.bytes.subarray(12)); + const fed = conn.feed(second.bytes); + assert.equal(fed.event.protocol, 3); + assert.equal(fed.event.callId, 4); + big.free(); + conn.free(); +}); diff --git a/runtime/ffi/js/test/vectors.test.mjs b/runtime/ffi/js/test/vectors.test.mjs index 4802ca3..0d86fca 100644 --- a/runtime/ffi/js/test/vectors.test.mjs +++ b/runtime/ffi/js/test/vectors.test.mjs @@ -11,31 +11,32 @@ // // node --test runtime/ffi/js/test/vectors.test.mjs // -// `COMLINE_VECTORS` is the export's directory and `COMLINE_WASM` the module; -// they default to `ffi-vectors` and the release wasm32 build under -// `CARGO_TARGET_DIR` (or the repository's `target`). Each file fails on any -// mismatch, a malformed line, or a number of cases other than its header's. +// The whole replay runs once per way of loading the module (`LOADERS`), so +// each is proven on every case. Every encode is sized by `encodeBound`: with +// everything the frame holds given, the bound must be the exact size the +// ABI writes, so the encode never needs a retry; with only the lengths, it +// must be no smaller. +// +// `env.mjs` says where the export and the module are. Each file fails on +// any mismatch, a malformed line, or a number of cases other than its +// header's. -import { after, test } from "node:test"; +import { after, before, describe, test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; import { instantiate, + instantiateStreaming, + instantiateSync, + encodeBound, EventFlag, EventKind, Handshake, Status, } from "../comline.mjs"; - -const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); -const TARGET = process.env.CARGO_TARGET_DIR || join(ROOT, "target"); -const VECTORS = process.env.COMLINE_VECTORS || join(TARGET, "ffi-vectors"); -const WASM = - process.env.COMLINE_WASM || - join(TARGET, "wasm32-unknown-unknown", "release", "comline_runtime_ffi.wasm"); +import { VECTORS, WASM } from "./env.mjs"; const FILES = [ "name-hash", @@ -51,10 +52,23 @@ const FULL = "full"; const ID_PATCHED = "id-patched"; const OTHER_KIND = "rejected as the other kind"; const NOT_MAPPED = "not mapped"; -const counts = { [FULL]: 0, [ID_PATCHED]: 0, [OTHER_KIND]: 0, [NOT_MAPPED]: 0 }; -const comline = await instantiate(readFileSync(WASM)); -const utf8 = new TextEncoder(); +const bytes = readFileSync(WASM); +const served = (type) => new Response(bytes, { headers: { "content-type": type } }); + +/** Every way of loading the module, each run on every case. */ +const LOADERS = { + "instantiate (bytes)": () => instantiate(bytes), + "instantiate (module)": () => instantiate(new WebAssembly.Module(bytes)), + "instantiateSync (bytes)": async () => instantiateSync(bytes), + "instantiateSync (module)": async () => instantiateSync(new WebAssembly.Module(bytes)), + "instantiateStreaming": () => instantiateStreaming(served("application/wasm")), + "instantiateStreaming (from bytes)": () => + instantiateStreaming(Promise.resolve(served("application/octet-stream"))), +}; + +/** The instance the cases below run on: each loader's in turn. */ +let comline; /** A failed check: the replay records it against the case and goes on. */ class Mismatch extends Error {} @@ -250,6 +264,17 @@ function caseHandshake(how, f) { return FULL; } +/** Encode with `cap` set to `exact`, the bound with everything given, + * which must be exactly what's written; `loose`, the bound from lengths + * alone, must be no smaller. */ +function sizedEncode(exact, loose, encode) { + check(loose >= exact, `the loose bound ${loose} is under the exact ${exact}`); + const encoded = encode(exact); + check(encoded.status === Status.OK, `encode at the bound ${exact} gave ${encoded.status}`); + check(encoded.written === exact, `wrote ${encoded.written}, the bound is ${exact}`); + return encoded; +} + /** A `datagram@2` connection waiting on request 0, freed after `body`. */ function waitingOnZero(body) { return withConn(plain("datagram@2"), (conn) => { @@ -276,8 +301,12 @@ function caseEnvelope(how, f) { const frame = bytesField(f[3]); const want = bytesField(f[4]); withConn(plain("datagram@2"), (conn) => { - const encoded = conn.encodeReply(0, 0n, envelope, errorId, body, 256); - check(encoded.status === Status.OK, `encode gave ${encoded.status}`); + const reply = { framing: "datagram@2", kind: "reply", envelope, bodyLen: body.length }; + const encoded = sizedEncode( + encodeBound({ ...reply, protocol: 0, requestId: 0n, errorId }), + encodeBound(reply), + (cap) => conn.encodeReply(0, 0n, envelope, errorId, body, cap), + ); sameBytes(encoded.bytes, want, "encoded"); }); const fed = waitingOnZero((conn) => conn.feed(frame)); @@ -328,8 +357,14 @@ function caseRequest(how, f) { // else at 0, for which the export's bytes are. withConn(plain(framing), (conn) => { spendIds(conn, drive); - const encoded = conn.encodeRequest(protocol, callId, method, !oneWay, params, 4096); - check(encoded.status === Status.OK, `encode gave ${encoded.status}`); + const request = { framing, kind: "request", bodyLen: params.length }; + const encoded = sizedEncode( + encodeBound({ + ...request, protocol, name: method, expectsReply: !oneWay, requestId: drive, + }), + encodeBound({ ...request, nameLen: method.length }), + (cap) => conn.encodeRequest(protocol, callId, method, !oneWay, params, cap), + ); if (!oneWay) { check(encoded.requestId === drive, `encoded as request ${encoded.requestId}`); } @@ -389,8 +424,12 @@ function caseResponse(how, f) { // Encode: a reply carries the peer's request id, so nothing to drive. withConn(plain(framing), (conn) => { - const encoded = conn.encodeReply(protocol, requestId, envelope, errorId, body, 4096); - check(encoded.status === Status.OK, `encode gave ${encoded.status}`); + const reply = { framing, kind: "reply", envelope, bodyLen: body.length }; + const encoded = sizedEncode( + encodeBound({ ...reply, protocol, requestId, errorId }), + encodeBound(reply), + (cap) => conn.encodeReply(protocol, requestId, envelope, errorId, body, cap), + ); sameBytes(encoded.bytes, want, "encoded"); }); @@ -431,46 +470,60 @@ const OPS = { // ── the replay ───────────────────────────────────────────────────────────── -for (const feature of FILES) { - test(feature, () => { - const path = join(VECTORS, `${feature}.txt`); - const text = readFileSync(path, "latin1"); - assert.ok(text.endsWith("\n"), `${path}: unterminated`); - const [header, ...lines] = text.slice(0, -1).split("\n"); - const head = header.split(" "); - assert.ok( - head.length === 4 && - head[0] === "comline-ffi-vectors" && - head[1] === "1" && - head[2] === feature && - /^[0-9]+$/.test(head[3]), - `${path}: not a version 1 export of ${feature}`, - ); - const expected = Number(head[3]); - const failures = []; - let passed = 0; - for (const line of lines) { - const [op, id, how, ...fields] = line.split(" "); - try { - assert.ok(how !== undefined && OPS[op], `malformed line: ${line.slice(0, 60)}`); - counts[OPS[op](how, fields)]++; - passed++; - } catch (e) { - failures.push(`${id}: ${e.message}`); - } +/** Replays one export file on `comline`; its counts go to `counts`. */ +function replay(feature, counts) { + const path = join(VECTORS, `${feature}.txt`); + const text = readFileSync(path, "latin1"); + assert.ok(text.endsWith("\n"), `${path}: unterminated`); + const [header, ...lines] = text.slice(0, -1).split("\n"); + const head = header.split(" "); + assert.ok( + head.length === 4 && + head[0] === "comline-ffi-vectors" && + head[1] === "1" && + head[2] === feature && + /^[0-9]+$/.test(head[3]), + `${path}: not a version 1 export of ${feature}`, + ); + const expected = Number(head[3]); + const failures = []; + let passed = 0; + for (const line of lines) { + const [op, id, how, ...fields] = line.split(" "); + try { + assert.ok(how !== undefined && OPS[op], `malformed line: ${line.slice(0, 60)}`); + counts[OPS[op](how, fields)]++; + passed++; + } catch (e) { + failures.push(`${id}: ${e.message}`); } - if (lines.length !== expected) { - failures.push(`${path}: replayed ${lines.length} cases, the header says ${expected}`); + } + if (lines.length !== expected) { + failures.push(`${path}: replayed ${lines.length} cases, the header says ${expected}`); + } + console.log(`${feature}: ${passed}/${expected} passed`); + assert.deepEqual(failures, []); +} + +for (const [loader, load] of Object.entries(LOADERS)) { + describe(loader, () => { + const counts = { [FULL]: 0, [ID_PATCHED]: 0, [OTHER_KIND]: 0, [NOT_MAPPED]: 0 }; + let instance; + before(async () => { + instance = await load(); + }); + for (const feature of FILES) { + test(feature, () => { + comline = instance; + replay(feature, counts); + }); } - console.log(`${feature}: ${passed}/${expected} passed`); - assert.deepEqual(failures, []); + after(() => { + const total = Object.values(counts).reduce((a, b) => a + b, 0); + console.log( + `${loader}: ${total} cases: ${counts[FULL]} full, ${counts[ID_PATCHED]} id-patched, ` + + `${counts[OTHER_KIND]} rejected as the other kind, ${counts[NOT_MAPPED]} not mapped`, + ); + }); }); } - -after(() => { - const total = Object.values(counts).reduce((a, b) => a + b, 0); - console.log( - `${total} cases: ${counts[FULL]} full, ${counts[ID_PATCHED]} id-patched, ` + - `${counts[OTHER_KIND]} rejected as the other kind, ${counts[NOT_MAPPED]} not mapped`, - ); -}); diff --git a/runtime/ffi/src/lib.rs b/runtime/ffi/src/lib.rs index 8a05a5a..3acfbce 100644 --- a/runtime/ffi/src/lib.rs +++ b/runtime/ffi/src/lib.rs @@ -39,8 +39,17 @@ //! panic is a bug: the call returns `COMLINE_ERR_POISONED`, the handle is //! marked poisoned and every later call on it returns the same, so the //! binding drops the connection. A free function returns -//! `COMLINE_ERR_POISONED` for the call that panicked. On wasm32 a panic is -//! a trap instead, and the host discards the instance. +//! `COMLINE_ERR_POISONED` for the call that panicked. +//! +//! **On wasm32 the instance is poisoned, not just the handle.** There a +//! panic doesn't unwind: it aborts, which is a trap (a +//! `WebAssembly.RuntimeError` in JS) that `catch_unwind` can't catch, so +//! the module never returns `COMLINE_ERR_POISONED` itself. The trap stops +//! the module mid-call and leaves the whole instance (its stack pointer, +//! its heap, every handle in it) unusable. The host marks the instance +//! dead and answers every later call on it, and on every handle from it, +//! with `COMLINE_ERR_POISONED`, never entering the module again; then it +//! drops the instance and makes a new one. `js/comline.mjs` does this. //! - **Threads.** A handle may move between threads, but calls on one handle //! must not overlap. Different handles are independent, and the free //! functions are pure. @@ -1494,6 +1503,23 @@ pub unsafe extern "C" fn comline_method_name_unescape( /// Linear-memory regions for a wasm32 host, which can't pass pointers into /// its own heap: it copies input into the `in` region and reads output from /// the `out` region. Not in the header; wasm32 only. +/// +/// What a host may rely on, so it can keep a region's address and size +/// between calls and reserve again only when a call needs more (as +/// `js/comline.mjs` does): +/// +/// - A region moves, grows or is freed only inside its own reserve call, +/// and only when that call asks for more bytes than it holds. A reserve +/// for no more returns the same address and changes nothing, contents +/// included. Nothing else in the module touches a region: no ABI function +/// allocates into it, frees it or writes to it except through the +/// pointers the host passes. +/// - A region is never shrunk or freed; it lives as long as the instance. +/// - Reserving one region never moves or changes the other. +/// - Memory may grow during any call (a reserve, or an allocation such as +/// `comline_connection_new`'s), which detaches the host's views of it but +/// moves nothing: every address stays valid, so the host only takes its +/// views again. #[cfg(all(target_arch = "wasm32", not(target_feature = "atomics")))] mod wasm { use core::cell::UnsafeCell; @@ -1506,6 +1532,9 @@ mod wasm { static IN: Region = Region(UnsafeCell::new(Vec::new())); static OUT: Region = Region(UnsafeCell::new(Vec::new())); + /// The one place a region changes (see the module's doc): it only + /// grows, only when `len` is more than it holds, and is never shrunk + /// or freed. fn reserve(region: &Region, len: usize) -> *mut u8 { // SAFETY: one thread, and no reference to the region outlives a // call. @@ -1521,9 +1550,11 @@ mod wasm { } /// A region of at least `len` bytes for input, owned by the module; - /// null if memory can't grow. Reserving more may move it (and grow - /// memory, which detaches the host's views), so a host reserves up - /// front and takes the pointer again after each reserve. + /// null if memory can't grow, and then the region is as it was. + /// Reserving more than it holds may move it (and grow memory, which + /// detaches the host's views), so a host takes the pointer again after + /// such a reserve; a reserve for no more returns the same pointer and + /// changes nothing. #[no_mangle] pub extern "C" fn comline_wasm_reserve_in(len: usize) -> *mut u8 { reserve(&IN, len) From e3cb0e54d4e21e1c7f7091639d2950e14c91833b Mon Sep 17 00:00:00 2001 From: Kinflou Date: Tue, 27 Oct 2026 00:41:51 +0800 Subject: [PATCH 2/3] ffi/js: test encodeBound on random encodes, and keep each test's paths in it bound.test.mjs holds encodeBound to the ABI on random encodes: exact when every field is given, never under from lengths alone (and the encode at that bound succeeds first time), with its slack reported and a planted off-by-one that must be caught. The paths env.mjs held are back in each test, so node --test on the directory runs no helper as a test. --- runtime/ffi/js/test/bound.test.mjs | 266 +++++++++++++++++++++++++++ runtime/ffi/js/test/env.mjs | 23 --- runtime/ffi/js/test/poison.test.mjs | 19 +- runtime/ffi/js/test/regions.test.mjs | 12 +- runtime/ffi/js/test/vectors.test.mjs | 16 +- 5 files changed, 304 insertions(+), 32 deletions(-) create mode 100644 runtime/ffi/js/test/bound.test.mjs delete mode 100644 runtime/ffi/js/test/env.mjs diff --git a/runtime/ffi/js/test/bound.test.mjs b/runtime/ffi/js/test/bound.test.mjs new file mode 100644 index 0000000..720b02c --- /dev/null +++ b/runtime/ffi/js/test/bound.test.mjs @@ -0,0 +1,266 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// `encodeBound` against what the ABI actually writes, on random encodes: +// names with every control character, quotes, backslashes and non-ASCII +// (as strings, lone surrogates included, and as bytes), protocols and ids +// across their ranges (reply ids up to `u64::MAX`), bodies of 0 to 70000 +// bytes, both framings, both kinds and every envelope, in message and +// stream mode. +// +// Each encode is checked twice. Given everything the frame holds, the bound +// must be exactly `*written`. From lengths alone (`nameLen`, no protocol, +// ids or error id), it must be no smaller; the encode is made with that as +// `cap`, so it must succeed the first time. The slack of the loose bound is +// reported per framing and kind. Then the checker is run on a bound with an +// off-by-one planted in it, and must catch it, so a check that can't fail +// isn't mistaken for a proof. +// +// Request ids come from the core, from 0, so a request is encoded at the id +// a fresh connection gives after a few are spent; the digits of every id up +// to `u64::MAX` go through the same count on replies, whose id the caller +// gives. +// +// node --test runtime/ffi/js/test/bound.test.mjs +// +// `COMLINE_WASM` is the module; it defaults to the `wasm` profile's wasm32 +// build under `CARGO_TARGET_DIR` (or the repository's `target`). +// +// `COMLINE_BOUND_RUNS` (default 4000) and `COMLINE_BOUND_SEED` choose the +// run; a failure prints its seed and case. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Envelope, Handshake, Status, encodeBound, instantiateSync } from "../comline.mjs"; + +const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const TARGET = process.env.CARGO_TARGET_DIR || join(ROOT, "target"); +const WASM = + process.env.COMLINE_WASM || + join(TARGET, "wasm32-unknown-unknown", "wasm", "comline_runtime_ffi.wasm"); + +const comline = instantiateSync(readFileSync(WASM)); +const RUNS = Number(process.env.COMLINE_BOUND_RUNS ?? 4000); +const SEED = Number(process.env.COMLINE_BOUND_SEED ?? 0x5eed); + +/** A small seeded generator (mulberry32), so a failure can be replayed. */ +function rng(seed) { + let a = seed >>> 0; + const next = () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const int = (n) => Math.floor(next() * n); + const pick = (xs) => xs[int(xs.length)]; + return { next, int, pick }; +} + +/** A random method name, as a string: control characters, quotes, + * backslashes, DEL, ASCII, and 2-, 3- and 4-byte UTF-8, lone surrogates + * too; sometimes nothing but `\u00XX` escapes, the most a byte can take. */ +function nameOf(r) { + const len = r.pick([0, 1, 2, r.int(8), r.int(40), r.int(300)]); + const onlyControls = r.int(8) === 0; + let s = ""; + for (let i = 0; i < len; i++) { + if (onlyControls) { + s += String.fromCharCode(r.pick([0x00, 0x01, 0x07, 0x0b, 0x0e, 0x1f])); + continue; + } + switch (r.int(9)) { + case 0: s += String.fromCharCode(r.int(0x20)); break; + case 1: s += r.pick(['"', "\\", "\n", "\r", "\t", "\b", "\f", "/"]); break; + case 2: s += "\x7f"; break; + case 3: s += String.fromCharCode(0x80 + r.int(0x780)); break; + case 4: s += String.fromCharCode(0x800 + r.int(0xd000 - 0x800)); break; + case 5: s += String.fromCodePoint(0x10000 + r.int(0x100000)); break; + case 6: s += String.fromCharCode(0xd800 + r.int(0x800)); break; // lone + default: s += String.fromCharCode(0x20 + r.int(0x5f)); + } + } + return s; +} + +const U64_MAX = (1n << 64n) - 1n; + +function idOf(r) { + switch (r.int(4)) { + case 0: return r.pick([0n, 9n, 10n, 99n, 100n, U64_MAX, U64_MAX - 1n, 1n << 63n, 10n ** 19n - 1n, 10n ** 19n]); + case 1: return BigInt(r.int(1000)); + case 2: return 10n ** BigInt(r.int(20)); + default: { + const hi = BigInt(r.int(2 ** 32)); + const lo = BigInt(r.int(2 ** 32)); + return (hi << 32n) | lo; + } + } +} + +const u16Of = (r) => r.pick([0, 1, 127, 128, 16383, 16384, 65535, r.int(65536)]); + +function bodyLenOf(r) { + return r.pick([0, 1, 2, 3, 4, r.int(64), r.int(1024), 65535, 65536, 70000, r.int(70001)]); +} + +/** One random encode: its arguments, and the bound's two views of it. */ +function caseOf(r) { + const framing = r.pick(["datagram@2", "jsonrpc@1"]); + const stream = r.int(2) === 1; + const kind = r.pick(["request", "reply"]); + const bodyLen = bodyLenOf(r); + const protocol = u16Of(r); + if (kind === "request") { + const string = nameOf(r); + const name = r.int(2) ? string : new TextEncoder().encode(string); + return { + framing, stream, kind, bodyLen, protocol, name, + callId: u16Of(r), + expectsReply: r.int(2) === 1, + spend: r.int(12), + }; + } + return { + framing, stream, kind, bodyLen, protocol, + envelope: r.pick([Envelope.OK, Envelope.ERR, Envelope.FAULT]), + errorId: u16Of(r), + requestId: idOf(r), + }; +} + +/** The UTF-8 length of a name as the encode sends it. */ +const utf8Len = (name) => (typeof name === "string" ? new TextEncoder().encode(name).length : name.length); + +const bodies = new Map(); +const bodyOf = (len) => { + if (!bodies.has(len)) bodies.set(len, Uint8Array.from({ length: len }, (_, i) => (i * 37 + 11) & 0xff)); + return bodies.get(len); +}; + +/** + * Encodes `c` through the ABI, sized by `bound`, and returns what's wrong: + * nothing when the exact bound is `*written` and the loose one is no + * smaller (and its encode succeeds first time). Records the loose slack. + */ +function checkCase(c, bound, slack) { + const { connection: conn } = comline.connectionNew({ + framing: c.framing, + stream: c.stream, + handshake: Handshake.NONE, + maxWaiting: 16, + unordered: true, + }); + try { + const body = c.envelope === Envelope.FAULT ? new Uint8Array(0) : bodyOf(c.bodyLen); + const shape = { framing: c.framing, stream: c.stream, kind: c.kind, bodyLen: body.length }; + let exact; + let loose; + let encode; + if (c.kind === "request") { + for (let i = 0; i < c.spend; i++) conn.encodeRequest(0, 0, "", false, "", 64); + exact = bound({ + ...shape, protocol: c.protocol, name: c.name, expectsReply: c.expectsReply, + requestId: BigInt(c.spend), + }); + loose = bound({ ...shape, nameLen: utf8Len(c.name) }); + encode = (cap) => conn.encodeRequest(c.protocol, c.callId, c.name, c.expectsReply, body, cap); + } else { + exact = bound({ ...shape, protocol: c.protocol, envelope: c.envelope, errorId: c.errorId, requestId: c.requestId }); + loose = bound({ ...shape, envelope: c.envelope }); + encode = (cap) => conn.encodeReply(c.protocol, c.requestId, c.envelope, c.errorId, body, cap); + } + // At the loose bound: it must fit the first time. + const encoded = encode(loose); + if (encoded.status !== Status.OK) return `at the loose bound ${loose}: status ${encoded.status}`; + const written = encoded.written; + if (c.kind === "request" && c.expectsReply && encoded.requestId !== BigInt(c.spend)) { + return `request id ${encoded.requestId}, expected ${c.spend}`; + } + if (exact !== written) return `the exact bound is ${exact}, ${written} written`; + if (loose < written) return `the loose bound ${loose} is under ${written}`; + const key = `${c.framing} ${c.kind}`; + const s = (slack[key] ??= { n: 0, max: 0, sum: 0, maxRel: 0 }); + s.n++; + s.sum += loose - written; + s.max = Math.max(s.max, loose - written); + s.maxRel = Math.max(s.maxRel, (loose - written) / written); + return null; + } finally { + conn.free(); + } +} + +/** Runs the property on `runs` cases; the failures, and the slack. */ +function run(bound, runs, seed) { + const r = rng(seed); + const failures = []; + const slack = {}; + for (let i = 0; i < runs; i++) { + const c = caseOf(r); + const why = checkCase(c, bound, slack); + if (why) failures.push({ i, why, c }); + } + return { failures, slack }; +} + +const show = (c) => JSON.stringify(c, (_, v) => + typeof v === "bigint" ? `${v}n` : v instanceof Uint8Array ? `bytes:${Buffer.from(v).toString("hex")}` : v); + +test(`encodeBound is exact, and never under, on ${RUNS} random encodes (seed ${SEED})`, () => { + const { failures, slack } = run(encodeBound, RUNS, SEED); + for (const [key, s] of Object.entries(slack).sort()) { + console.log( + `${key}: ${s.n} cases; the loose bound's slack: mean ${(s.sum / s.n).toFixed(1)} B, ` + + `max ${s.max} B, max ${(100 * s.maxRel).toFixed(0)}% of the frame`, + ); + } + assert.deepEqual( + failures.slice(0, 5).map(({ i, why, c }) => `case ${i}: ${why}: ${show(c)}`), + [], + ); +}); + +test("a planted off-by-one fails the check", () => { + // One byte short, everywhere: the exact bound is then always under. + const short = (args) => encodeBound(args) - 1; + const { failures } = run(short, 200, SEED + 1); + assert.equal(failures.length, 200, "every case must catch a bound one byte short"); + // One byte short only on one path that's easy to miss: the `\u00XX` + // escapes of a name counted at 5 bytes a byte when only its length is + // given. + const fiveEscape = (args) => + args.nameLen != null && args.framing === "jsonrpc@1" + ? encodeBound(args) - args.nameLen + : encodeBound(args); + const { failures: caught } = run(fiveEscape, 2000, SEED + 2); + assert.ok(caught.length > 0, "a 5-byte escape went unnoticed"); +}); + +test("the loose bound is tight for names of `\\u00XX` escapes alone", () => { + // A one-way request has no id, so only the name is loose; a name of + // control characters without a short escape takes exactly 6 bytes a byte. + const { connection: conn } = comline.connectionNew({ framing: "jsonrpc@1", handshake: Handshake.NONE }); + for (const len of [0, 1, 2, 17, 255]) { + const name = "\x01".repeat(len); + const bound = encodeBound({ framing: "jsonrpc@1", kind: "request", nameLen: len, expectsReply: false, bodyLen: 3 }); + const sent = conn.encodeRequest(0, 0, name, false, "[1]", bound); + assert.equal(sent.status, Status.OK); + assert.equal(sent.written, bound, `a ${len}-byte name`); + } + conn.free(); +}); + +test("encodeBound refuses what it can't size", () => { + assert.throws(() => encodeBound({ framing: "datagram@1", kind: "request", bodyLen: 0 }), TypeError); + assert.throws(() => encodeBound({ framing: "datagram@2", kind: "call", bodyLen: 0 }), TypeError); + assert.throws(() => encodeBound({ framing: "datagram@2", kind: "reply", bodyLen: 0 }), TypeError); + assert.throws(() => encodeBound({ framing: "datagram@2", kind: "request", bodyLen: -1 }), TypeError); + assert.throws(() => encodeBound({ framing: "jsonrpc@1", kind: "request", bodyLen: 0 }), TypeError); +}); diff --git a/runtime/ffi/js/test/env.mjs b/runtime/ffi/js/test/env.mjs deleted file mode 100644 index 907bc11..0000000 --- a/runtime/ffi/js/test/env.mjs +++ /dev/null @@ -1,23 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at https://mozilla.org/MPL/2.0/. - -// Where the Node tests find what they run against. Each defaults to its -// place under `CARGO_TARGET_DIR` (or the repository's `target`): -// -// - `COMLINE_WASM`: the wasm32 build, `--profile wasm`; -// - `COMLINE_WASM_TEST_HOOKS`: the same with `--features test-hooks`, built -// into `/test-hooks` so it doesn't replace the other; -// - `COMLINE_VECTORS`: the vectors `examples/export_vectors.rs` exports. - -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); -const TARGET = process.env.CARGO_TARGET_DIR || join(ROOT, "target"); -const BUILD = join("wasm32-unknown-unknown", "wasm", "comline_runtime_ffi.wasm"); - -export const WASM = process.env.COMLINE_WASM || join(TARGET, BUILD); -export const WASM_TEST_HOOKS = - process.env.COMLINE_WASM_TEST_HOOKS || join(TARGET, "test-hooks", BUILD); -export const VECTORS = process.env.COMLINE_VECTORS || join(TARGET, "ffi-vectors"); diff --git a/runtime/ffi/js/test/poison.test.mjs b/runtime/ffi/js/test/poison.test.mjs index e4af17b..3b2643f 100644 --- a/runtime/ffi/js/test/poison.test.mjs +++ b/runtime/ffi/js/test/poison.test.mjs @@ -3,22 +3,33 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. // The wasm32 poisoning rule: a trap poisons the whole instance. Against the -// `test-hooks` build (`env.mjs`), whose `comline_test_panic_next` makes the -// next call on a handle panic inside its guard, as `tests/rules.rs` does -// natively. On wasm32 that panic is a trap the guard can't catch, so the +// `test-hooks` build, whose `comline_test_panic_next` makes the next call +// on a handle panic inside its guard, as `tests/rules.rs` does natively. On wasm32 that panic is a trap the guard can't catch, so the // glue must mark the instance dead: the call that trapped and every later // call on the instance and on each of its connections return // `ERR_POISONED`, without entering the module, while a fresh instance // works. // // node --test runtime/ffi/js/test/poison.test.mjs +// +// `COMLINE_WASM_TEST_HOOKS` is the module; it defaults to the `wasm` +// profile's wasm32 build with `--features test-hooks`, built into +// `test-hooks` under `CARGO_TARGET_DIR` (or the repository's `target`) so +// it doesn't replace the build without them. import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { Comline, Envelope, EventKind, Handshake, Status, instantiateSync } from "../comline.mjs"; -import { WASM_TEST_HOOKS } from "./env.mjs"; + +const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const TARGET = process.env.CARGO_TARGET_DIR || join(ROOT, "target"); +const WASM_TEST_HOOKS = + process.env.COMLINE_WASM_TEST_HOOKS || + join(TARGET, "test-hooks", "wasm32-unknown-unknown", "wasm", "comline_runtime_ffi.wasm"); const bytes = readFileSync(WASM_TEST_HOOKS); const module = new WebAssembly.Module(bytes); diff --git a/runtime/ffi/js/test/regions.test.mjs b/runtime/ffi/js/test/regions.test.mjs index 46de5b1..8068e05 100644 --- a/runtime/ffi/js/test/regions.test.mjs +++ b/runtime/ffi/js/test/regions.test.mjs @@ -7,13 +7,23 @@ // enters the module once per call. // // node --test runtime/ffi/js/test/regions.test.mjs +// +// `COMLINE_WASM` is the module; it defaults to the `wasm` profile's wasm32 +// build under `CARGO_TARGET_DIR` (or the repository's `target`). import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { Comline, Envelope, EventKind, Handshake, Status } from "../comline.mjs"; -import { WASM } from "./env.mjs"; + +const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const TARGET = process.env.CARGO_TARGET_DIR || join(ROOT, "target"); +const WASM = + process.env.COMLINE_WASM || + join(TARGET, "wasm32-unknown-unknown", "wasm", "comline_runtime_ffi.wasm"); const module = new WebAssembly.Module(readFileSync(WASM)); const utf8 = new TextEncoder(); diff --git a/runtime/ffi/js/test/vectors.test.mjs b/runtime/ffi/js/test/vectors.test.mjs index 0d86fca..e8546d6 100644 --- a/runtime/ffi/js/test/vectors.test.mjs +++ b/runtime/ffi/js/test/vectors.test.mjs @@ -17,14 +17,16 @@ // ABI writes, so the encode never needs a retry; with only the lengths, it // must be no smaller. // -// `env.mjs` says where the export and the module are. Each file fails on -// any mismatch, a malformed line, or a number of cases other than its -// header's. +// `COMLINE_VECTORS` is the export's directory and `COMLINE_WASM` the module; +// they default to `ffi-vectors` and the `wasm` profile's wasm32 build under +// `CARGO_TARGET_DIR` (or the repository's `target`). Each file fails on any +// mismatch, a malformed line, or a number of cases other than its header's. import { after, before, describe, test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { instantiate, @@ -36,7 +38,13 @@ import { Handshake, Status, } from "../comline.mjs"; -import { VECTORS, WASM } from "./env.mjs"; + +const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); +const TARGET = process.env.CARGO_TARGET_DIR || join(ROOT, "target"); +const VECTORS = process.env.COMLINE_VECTORS || join(TARGET, "ffi-vectors"); +const WASM = + process.env.COMLINE_WASM || + join(TARGET, "wasm32-unknown-unknown", "wasm", "comline_runtime_ffi.wasm"); const FILES = [ "name-hash", From 67ccb6c673a73be3b4fa21b112cc7811a7586cf1 Mon Sep 17 00:00:00 2001 From: Kinflou Date: Tue, 27 Oct 2026 00:41:51 +0800 Subject: [PATCH 3/3] ffi: a wasm build profile; CI builds the test-hooks wasm and runs every Node test The wasm profile inherits release with fat LTO, one codegen unit and panic = "abort" (what wasm32 does anyway). Native builds are unchanged. The README records the wasm32 rules and the sizes and timings per opt-level. --- .github/workflows/build_test.yml | 16 ++++-- Cargo.toml | 13 ++--- runtime/ffi/README.md | 91 +++++++++++++++++++++++++++++--- 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 076ea0d..c05a1d2 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -50,7 +50,9 @@ jobs: # cdylib and a staticlib natively and as a wasm32 module. The conformance # vectors are replayed through it from Rust (`cargo test`), from C against # the staticlib and the cdylib (gcc, and ASan + UBSan), and from Node - # against the wasm32 build, each on the same cases. + # against the wasm32 build, each on the same cases. The Node tests also + # hold the JS glue to its own rules: a trap poisons the instance (against + # a `test-hooks` wasm32 build), one crossing per call, and `encodeBound`. ffi: name: ffi runs-on: ubuntu-latest @@ -77,8 +79,14 @@ jobs: ls -l target/release/libcomline_runtime_ffi.so target/release/libcomline_runtime_ffi.a - name: wasm32 run: | - cargo build --verbose -p comline-runtime-ffi --release --target wasm32-unknown-unknown - ls -l target/wasm32-unknown-unknown/release/comline_runtime_ffi.wasm + cargo build --verbose -p comline-runtime-ffi --profile wasm --target wasm32-unknown-unknown + ls -l target/wasm32-unknown-unknown/wasm/comline_runtime_ffi.wasm + # Into a target directory of its own, so it doesn't replace the build + # above; js/test/poison.test.mjs looks for it there. + - name: wasm32 with test hooks + run: | + cargo build --verbose -p comline-runtime-ffi --profile wasm --target wasm32-unknown-unknown --features test-hooks --target-dir target/test-hooks + ls -l target/test-hooks/wasm32-unknown-unknown/wasm/comline_runtime_ffi.wasm - run: cargo test --verbose -p comline-runtime-ffi - name: export the vectors for C and Node run: cargo run -p comline-runtime-ffi --example export_vectors -- target/ffi-vectors @@ -88,7 +96,7 @@ jobs: with: node-version: 22 - name: Node harness (wasm32) - run: node --test runtime/ffi/js/test/vectors.test.mjs + run: node --test runtime/ffi/js/test/*.test.mjs # The C ABI under Miri: the replay and the rule tests, checked for # undefined behaviour at the `extern "C"` boundary (aliasing, bounds, diff --git a/Cargo.toml b/Cargo.toml index 6dca0a7..5675bd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,14 +28,15 @@ opt-level = 2 # cargo build -p comline-runtime-ffi --profile wasm --target wasm32-unknown-unknown # For wasm32 only: native builds keep `release`, and the instruction-count # benches `bench`. One codegen unit and fat LTO let the core inline into -# each export. `panic = "abort"` is what wasm32-unknown-unknown does anyway -# (a panic is a trap, which poisons the whole instance; see -# runtime/ffi/src/lib.rs), so it changes nothing there. Never use this -# profile natively, where the ABI's guards need a panic to unwind. The -# sizes and timings it was chosen by are in runtime/ffi/README.md. +# each export, and `release`'s `opt-level` stays: 3, "s" and "z" measured no +# faster (and "s"/"z" slower on `jsonrpc@1`). `panic = "abort"` is what +# wasm32-unknown-unknown does anyway (a panic is a trap, which poisons the +# whole instance; see runtime/ffi/src/lib.rs), so it changes nothing there. +# Never use this profile natively, where the ABI's guards need a panic to +# unwind. The measurements are in runtime/ffi/README.md; the JS instruction +# counts will settle `opt-level`. [profile.wasm] inherits = "release" -opt-level = 3 lto = "fat" codegen-units = 1 panic = "abort" diff --git a/runtime/ffi/README.md b/runtime/ffi/README.md index 8a7afc8..25c7f0a 100644 --- a/runtime/ffi/README.md +++ b/runtime/ffi/README.md @@ -10,7 +10,7 @@ and dispatch stay in each host. (`cbindgen.toml`) and checked in. CI fails when it drifts: `cbindgen --config cbindgen.toml --output include/comline.h`. - `js/comline.mjs`, `js/comline.d.mts`: hand-written JS glue over the - wasm32 build, with no dependencies. + wasm32 build, with no dependencies. See [The wasm32 build](#the-wasm32-build). ## Proving the boundary @@ -43,16 +43,23 @@ make -C runtime/ffi/tests/c # CC=gcc or CC=clang ``` **Node** (`js/test/vectors.test.mjs`, `node --test`, built-ins only) against -the release wasm32 build, after the export: +the wasm32 build, after the export, once through each way of loading it +(`instantiate`, `instantiateSync` and `instantiateStreaming`, from bytes +and from a module): ```sh -cargo build -p comline-runtime-ffi --release --target wasm32-unknown-unknown -node --test runtime/ffi/js/test/vectors.test.mjs +cargo build -p comline-runtime-ffi --profile wasm --target wasm32-unknown-unknown +cargo build -p comline-runtime-ffi --profile wasm --target wasm32-unknown-unknown \ + --features test-hooks --target-dir "${CARGO_TARGET_DIR:-target}/test-hooks" +node --test runtime/ffi/js/test/*.test.mjs ``` +The other Node tests are the glue's own (below): `poison.test.mjs` against +the `test-hooks` build, and `regions.test.mjs` and `bound.test.mjs`. + The C and Node harnesses look under `CARGO_TARGET_DIR` (or `target`); -`COMLINE_VECTORS` and `COMLINE_WASM` point Node elsewhere, and `VECTORS` -does the same for `make`. +`COMLINE_VECTORS`, `COMLINE_WASM` and `COMLINE_WASM_TEST_HOOKS` point Node +elsewhere, and `VECTORS` does the same for `make`. **Against the core, on random sequences.** Beyond the vectors, the ABI is run against `comline-runtime` itself: random configs and sequences (calls, @@ -87,3 +94,75 @@ is built as a host links it, without this crate's `test-hooks`. ```sh cargo bench -p comline-runtime-ffi-bench # needs Valgrind and iai-callgrind-runner ``` + +## The wasm32 build + +The same `extern "C"` functions are the module's exports, with two more +for the glue, `comline_wasm_reserve_in` and `comline_wasm_reserve_out`. It +is built with the `wasm` profile (the root `Cargo.toml`), and only wasm32 +is: native builds keep `release`. + +**A trap poisons the instance, not just the handle.** On wasm32 a panic +doesn't unwind: it aborts, which is a trap (`WebAssembly.RuntimeError`) +that the ABI's `catch_unwind` can't catch, so the module never returns +`COMLINE_ERR_POISONED` itself, and the trap leaves the whole instance +unusable. `comline.mjs` marks the instance dead on a trap (or on a +`RangeError` from inside the module, the engine's stack exhaustion): that +call and every later one on the instance and on each of its connections +return `Status.ERR_POISONED` without entering the module. A binding drops +the instance and its connections and instantiates again. Other exceptions, +such as a `TypeError` for a bad argument, are thrown and poison nothing. +`js/test/poison.test.mjs` proves it on the `test-hooks` build, whose +`comline_test_panic_next` makes the next call on a handle panic. + +**One crossing per call.** The glue keeps where the `in` and `out` regions +are and how big, and reserves again only when a call needs more. That +rests on the module's promise (`mod wasm` in `src/lib.rs`): a region moves, +grows or is freed only inside its own reserve call, and only when asked +for more than it holds. Memory may still grow in any call, which detaches +the glue's views but moves nothing, so the glue takes them again when +`memory.buffer` changes. `js/test/regions.test.mjs` holds the module to +the promise (through allocations, frees and memory growth) and the glue to +one crossing per call. + +**Loading.** `instantiate(bytes | module)`; `instantiateSync(bytes | +module)` for Node and workers (a browser's main thread refuses to compile a +module this size synchronously); `instantiateStreaming(response)` for +browsers, which reads the whole body and compiles that instead when the +engine has no `WebAssembly.instantiateStreaming` or the response isn't +`application/wasm`. Each checks the ABI version and the layouts the same +way. + +**Sizing an encode.** `encodeBound({ framing, stream, kind, bodyLen, ... })` +is the most bytes an encode can write, so `cap` never needs a retry. Given +everything the frame holds (`datagram@2`: `protocol`; `jsonrpc@1`: the +`name`, ids and error id) it is exact, from the framings' writers in +`comline-runtime`: `datagram@2`'s fixed header with its protocol varint, +and `jsonrpc@1`'s fixed members, its name escaped as serde_json escapes it, +and decimal ids. Given only lengths it is never smaller: a name byte +counted at 6 (`\u00XX`), an id at 20 digits, an error id at 5, a varint at +3. The vector harness encodes every case at the exact bound, which must be +what is written; `js/test/bound.test.mjs` does the same on random encodes +(every control character, quotes, backslashes, non-ASCII and lone +surrogates in names, reply ids up to `u64::MAX`, bodies to 70000 bytes, +stream and message mode), encodes at the loose bound, which must succeed +the first time, reports its slack, and catches a planted off-by-one. + +**The profile.** `wasm` inherits `release` (`opt-level = 2`) with +`lto = "fat"`, `codegen-units = 1` and `panic = "abort"` (wasm32's own +behaviour, so the trap test holds unchanged). Measured on the glue's +round trip (two encodes and two feeds, a 230-byte body, Node 20; wall time +on a loaded machine, so only differences of 10% or more mean anything): + +| build | `.wasm` | gzip -9 | `datagram@2` | `jsonrpc@1` | +|---|---|---|---|---| +| `release` (opt 2, 16 units, no fat LTO) | 208,930 | 77,971 | 4.7–5.5 µs | 5.9–6.4 µs | +| `wasm`, opt 2 (the default) | 197,771 | 76,325 | 4.6–5.5 µs | 6.1–7.2 µs | +| `wasm`, opt 3 | 198,061 | 76,345 | 4.6–5.5 µs | 6.8–7.0 µs | +| `wasm`, opt "s" | 174,152 | 63,568 | 4.8–5.0 µs | 7.4–7.6 µs | +| `wasm`, opt "z" | 187,903 | 66,517 | 5.3–5.9 µs | 9.4–9.9 µs | + +The glue's call itself dominates these, so `opt-level` is left to the +TypeScript runtime's instruction counts, which measure what wall time +can't. +