Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ 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, 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"
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]
Expand Down
91 changes: 85 additions & 6 deletions runtime/ffi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

66 changes: 65 additions & 1 deletion runtime/ffi/js/comline.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Comline>;

/** `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<Response>,
): Promise<Comline>;

/** 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 };
Expand All @@ -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;
Expand Down
Loading
Loading