Skip to content

ffi/js: glue fit for a binding (poisoning, one crossing, loading, encode sizing, wasm profile) - #62

Open
Kinflou wants to merge 3 commits into
mainfrom
ffi/js-binding-fit
Open

Kinflou wants to merge 3 commits into
mainfrom
ffi/js-binding-fit

Conversation

@Kinflou

@Kinflou Kinflou commented Oct 26, 2026

Copy link
Copy Markdown
Member

Step R1 of stage 4 (TypeScript on the shared core, through wasm): the JS glue over the wasm32 build of comline-runtime-ffi, made fit for a real binding. The C ABI and comline.h don't change, and native builds don't either.

What changed

A trap poisons the instance. On wasm32 a panic doesn't unwind. It aborts, which is a trap (WebAssembly.RuntimeError) that catch_unwind can't catch. So the module never returns ERR_POISONED itself, and the trap leaves the whole instance (stack pointer, heap, every handle) unusable.

  • The glue wraps every crossing. On a trap it marks the Comline instance dead, and that call returns Status.ERR_POISONED.
  • Every later call on the instance, and on any Connection made from it, returns ERR_POISONED without entering the module. free() becomes a no-op, and comline.poisoned reports the state.
  • A RangeError thrown from inside an export also counts as a trap. That is how V8 reports wasm stack exhaustion, which stops the module mid-call just as hard. The glue's own "memory can't grow" RangeError is tagged and doesn't poison, because the module is unchanged when a reserve fails.
  • Other exceptions are thrown as before and poison nothing. That covers a TypeError for a bad argument, whether the glue or the engine's argument conversion raises it.
  • A poisoned result is the status, with the fields its type always has set to zero (written: 0, consumed: 0 and a zeroed event, peer and connection null). supports returns false, as the C function does when it panics.
  • Documented in comline.mjs's header, comline.d.mts, the crate doc in lib.rs (which now says "the instance is poisoned, not just the handle"), and the README. The native per-handle rule is unchanged.

Fewer crossings. The glue caches the in/out region address and capacity. It reserves only when a call needs more, growing to at least 1.5x and to no less than 1 KiB, so a call is normally one crossing instead of three. Alignment and the 8-byte slack are as before. The views are taken again whenever memory.buffer changes. The module's side of the contract is now written down in mod wasm:

  • a region moves, grows or is freed only inside its own reserve call, and only when asked for more than it holds; a smaller reserve returns the same address and changes nothing;
  • regions are never shrunk or freed;
  • reserving one never moves the other;
  • memory growth from any allocation detaches views but moves nothing.

The code already kept these rules. Nothing in reserve changed except its doc.

In passing, *written is now zeroed before each out/cap/*written call. Before, a status where the ABI writes nothing (for example ERR_STATE on a closed connection) returned whatever written an earlier call had left in the region.

Loading. All three paths run the same constructor, so the same version check and layout probe:

  • instantiateSync(bytes | module) uses new WebAssembly.Module and new WebAssembly.Instance. It's for Node and workers; a browser's main thread refuses synchronous compiles of this size, and that is documented.
  • instantiateStreaming(response | promise) uses WebAssembly.instantiateStreaming. If that's missing, or the content type isn't application/wasm, it reads the whole body and compiles the bytes. A response that isn't ok is refused on both paths.

Encode sizing. encodeBound({ framing, stream, kind, bodyLen, ... }) is a pure function in the glue that returns the most bytes an encode can write, so a binding sizes cap once and never retries. It is derived from the writers in runtime/core:

  • datagram@2:
    • request 1 + varint(protocol) + 2 + 8 + body;
    • reply 1 + varint + 8 + envelope, where the envelope is 1 + body, 3 + body or 1;
    • the varint is 1, 2 or 3 bytes, at the 0x80 and 0x4000 boundaries.
  • jsonrpc@1: the fixed members as put_method, put_request and encode_response_* write them:
    • the name escaped exactly as put_method escapes it: ", \, \n, \r, \t, \b and \f take 2 bytes, other bytes below 0x20 take 6 (\u00XX), everything else 1;
    • for a string name, UTF-8 as TextEncoder writes it, with a lone surrogate as U+FFFD (3 bytes);
    • decimal ids as put_u64 writes them;
    • null for an empty body;
    • no ,"id":… on a notification.
  • Stream mode: +4.

The bound is exact when every field the frame holds is given. That means protocol for datagram@2, and the name, expectsReply, the request id when the binding knows it, and the reply id and error id for jsonrpc@1. From lengths alone it is never smaller: a name byte counts 6, an id 20 digits (u64::MAX), an error id 5, a varint 3. It stays in JS. An ABI export didn't look clearly better: the formula is fixed by the framings and checked against the ABI, and an export would cost a crossing and a header change.

wasm build profile. [profile.wasm] in the root Cargo.toml inherits release and adds lto = "fat", codegen-units = 1 and panic = "abort". On wasm32-unknown-unknown panic = "abort" is already the target's behaviour, so nothing changes there, and the trap test passes on this profile. opt-level stays release's 2 (see the measurements below). The profile is used only for wasm32 and documented as never for native builds, so native release/bench are untouched.

CI (ffi job, same name).

  • It builds the wasm with --profile wasm.
  • It also builds a --features test-hooks wasm into target/test-hooks, so the release-like one isn't replaced.
  • It runs node --test runtime/ffi/js/test/*.test.mjs.

The poisoning test (js/test/poison.test.mjs, on the test-hooks wasm)

  • comline_test_panic_next is exported on wasm32 (#[no_mangle] under the feature). After it, a raw export call throws WebAssembly.RuntimeError: the panic is a trap, not ERR_POISONED.

  • With two connections on one instance, the hook is set on the first, and its next feed traps. That call returns ERR_POISONED, and so do all of the following:

    • every connection call (start, feed, encodeRequest, encodeReply, abandon, isWaiting) on both connections;
    • every free function (nameHash, handshakeEncode, handshakeCheck, methodNameUnescape, connectionNew); supports is false.

    The exports are wrapped to count crossings, and the count doesn't move after the trap. free() doesn't enter the module either. A fresh instantiateSync then round-trips a request and reply.

  • The same check runs with the trap planted on each connection call in turn.

  • A RangeError from inside an export poisons.

  • None of these poison, and the instance keeps working after them:

    • a TypeError from the glue;
    • a RangeError from BigInt(1.5);
    • an engine TypeError at the boundary (a BigInt where a u16 goes);
    • a reserve that can't grow memory.

Regions (js/test/regions.test.mjs)

  • Through raw exports, the test fills in with a pattern, then does the following through out only:

    • 50 rounds of jsonrpc@1 connections whose decode allocates (runtime#58), frees, and reserves of out at or below its size, which must return the same address;
    • a max_waiting = 4,000,000 connection that grows memory.

    After that, in is at the same address with the same bytes. A larger reserve may move it and keeps the contents, and growing in doesn't move out.

  • Through the glue, with counted exports: encodeRequest, feed, encodeReply, isWaiting, abandon, nameHash, supports and handshakeEncode are each exactly one crossing and no reserve. A 100 KB body reserves in and out once each and is then one crossing again.

  • Memory growth between two calls (another connection's allocation) doesn't disturb a connection's next encode or feed.

Sizing verification

  • Every vector case (vectors.test.mjs) is encoded with cap set to the exact bound. It must succeed first time and write exactly the bound, and the loose bound must be no smaller. This holds for all 65 cases on every loading path.

  • Property test (bound.test.mjs, Node, no deps, seeded mulberry32): the random encodes cover

    • names from 0 to 300 chars mixing every control character, ", \, /, DEL, 2-, 3- and 4-byte UTF-8 and lone surrogates, as strings and as bytes, and sometimes nothing but \u00XX controls;
    • protocols, call ids and error ids at the edges and at random;
    • reply ids at the decimal-digit edges, 2^63 and u64::MAX, and at random over 64 bits;
    • request ids after 0 to 11 spent;
    • bodies at 0, 1–4, 65535, 65536, 70000 and random sizes up to 70000;
    • both framings, both kinds, every envelope, and stream and message mode.

    Each case is encoded at the loose bound, which must succeed first time. The exact bound must equal *written, and the loose bound must be ≥ it.

  • Request ids come from the core, starting at 0, so a request can't be driven to u64::MAX through the ABI. The id digit count is the same function on both kinds, and replies cover every id up to u64::MAX. The loose bound (20 digits) covers any request id.

  • Results: 4000 cases by default, plus 3 × 40,000 runs with seeds 1–3, with no failures. Loose-bound slack (seed 1, 40k):

    framing / kind mean slack max slack max relative
    datagram@2 reply 1.0 B 2 B 18%
    datagram@2 request 1.0 B 2 B 17%
    jsonrpc@1 reply 9.6 B 23 B 54%
    jsonrpc@1 request 252.7 B 2876 B 391% (non-ASCII names counted at 6 bytes a byte)

    The exact bound has zero slack by construction, and the test asserts that.

  • Planted bugs. These must be caught, and are:

    • encodeBound(...) - 1 fails all 200 cases;
    • counting a \u00XX escape at 5 bytes instead of 6 on the loose path is caught as well;
    • separately, a name made only of \u00XX controls with no id makes the loose bound equal *written, which proves the factor 6 is tight;
    • a manual check: changing JSONRPC_CLOSE by one fails the vector harness on every jsonrpc@1 case.

wasm sizes per profile

The round trip is two encodes and two feeds with a 230-byte body, glue included, timed on Node 20 on a loaded machine (load average about 5). Only differences of about 10% or more mean anything.

build .wasm gzip -9 datagram@2 round trip jsonrpc@1 round trip
release (before: opt 2, 16 units, no fat LTO) 208,930 77,971 4.7–5.5 µs 5.9–6.4 µs
wasm, opt 2 (chosen) 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 vector harness's wall time is about 0.3–0.6 s on every build, dominated by startup.
  • On the same .wasm, the new glue's round trip is about 30% faster than the old glue's: datagram@2 4.9 µs against 6.7–7.5 µs, jsonrpc@1 6.2–6.7 µs against 8.4–9.2 µs. That is the cached regions.
  • I chose opt 2: no measured gain from 3, and "s"/"z" are slower on jsonrpc@1. The decision belongs to R2's instruction counts, as planned.

ABI benches (base against head)

cargo bench -p comline-runtime-ffi-bench --bench ffi, with base saved as a baseline (--save-baseline=base) and head compared against it (--baseline=base), in the same worktree and target directory. Base was run on the untouched checkout before any change. The Rust changes are doc comments only, and the new profile isn't used by the benches. Result: 104 benchmarks, 0 regressed, every one "No change" in instructions.

Checks run locally

  • cargo test --workspace: all passed.
  • cargo clippy -p comline-runtime-ffi -p comline-runtime-ffi-bench --all-targets, and on wasm32 with test-hooks: no warnings in the ffi crates. The one warning is the existing non_local_definitions in runtime/core (sabi_trait), unchanged.
  • cargo fmt --check on the touched crates: clean.
  • cbindgen 0.29.2 regenerates comline.h unchanged.
  • C harness: cargo build --release, the export, then make -C runtime/ffi/tests/c check CC=clang (static, dynamic, ASan + UBSan): 65 cases each.
  • Node: node --test runtime/ffi/js/test/*.test.mjs, 48 tests passing on Node 20.18, 22.12 and 24.12. That is 6 loading paths × 6 files of the vectors (65 cases each), 5 poison tests, 3 region tests and 4 bound tests.
  • Miri: cargo +nightly miri test -p comline-runtime-ffi --features test-hooks: everything passed (27 rule tests, the vector replay, the differential suite; the existing jsonrpc_decode_allocates_nothing stays ignored for runtime#58).
  • Fuzz, 3 minutes each (cargo +nightly fuzz run --fuzz-dir runtime/fuzz <target> -- -max_total_time=180): ffi_feed 592,422 runs, ffi_vs_core 682,106 runs, no crashes.

Kinflou added 3 commits October 27, 2026 00:28
…s 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.
…ry 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant