Repository navigation
Conversation
added 3 commits
October 27, 2026 00:28
…nd streaming, size encodes WIP
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andcomline.hdon'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) thatcatch_unwindcan't catch. So the module never returnsERR_POISONEDitself, and the trap leaves the whole instance (stack pointer, heap, every handle) unusable.Comlineinstance dead, and that call returnsStatus.ERR_POISONED.Connectionmade from it, returnsERR_POISONEDwithout entering the module.free()becomes a no-op, andcomline.poisonedreports the state.RangeErrorthrown 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"RangeErroris tagged and doesn't poison, because the module is unchanged when a reserve fails.TypeErrorfor a bad argument, whether the glue or the engine's argument conversion raises it.written: 0,consumed: 0and a zeroed event,peerandconnectionnull).supportsreturnsfalse, as the C function does when it panics.comline.mjs's header,comline.d.mts, the crate doc inlib.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/outregion 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 whenevermemory.bufferchanges. The module's side of the contract is now written down inmod wasm:The code already kept these rules. Nothing in
reservechanged except its doc.In passing,
*writtenis now zeroed before eachout/cap/*writtencall. Before, a status where the ABI writes nothing (for exampleERR_STATEon a closed connection) returned whateverwrittenan 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)usesnew WebAssembly.Moduleandnew 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)usesWebAssembly.instantiateStreaming. If that's missing, or the content type isn'tapplication/wasm, it reads the whole body and compiles the bytes. A response that isn'tokis 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 sizescaponce and never retries. It is derived from the writers inruntime/core:datagram@2:1 + varint(protocol) + 2 + 8 + body;1 + varint + 8 + envelope, where the envelope is1 + body,3 + bodyor1;0x80and0x4000boundaries.jsonrpc@1: the fixed members asput_method,put_requestandencode_response_*write them:put_methodescapes it:",\,\n,\r,\t,\band\ftake 2 bytes, other bytes below 0x20 take 6 (\u00XX), everything else 1;TextEncoderwrites it, with a lone surrogate as U+FFFD (3 bytes);put_u64writes them;nullfor an empty body;,"id":…on a notification.The bound is exact when every field the frame holds is given. That means
protocolfordatagram@2, and thename,expectsReply, the request id when the binding knows it, and the reply id and error id forjsonrpc@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 rootCargo.tomlinheritsreleaseand addslto = "fat",codegen-units = 1andpanic = "abort". On wasm32-unknown-unknownpanic = "abort"is already the target's behaviour, so nothing changes there, and the trap test passes on this profile.opt-levelstays release's 2 (see the measurements below). The profile is used only for wasm32 and documented as never for native builds, so nativerelease/benchare untouched.CI (
ffijob, same name).--profile wasm.--features test-hookswasm intotarget/test-hooks, so the release-like one isn't replaced.node --test runtime/ffi/js/test/*.test.mjs.The poisoning test (
js/test/poison.test.mjs, on the test-hooks wasm)comline_test_panic_nextis exported on wasm32 (#[no_mangle]under the feature). After it, a raw export call throwsWebAssembly.RuntimeError: the panic is a trap, notERR_POISONED.With two connections on one instance, the hook is set on the first, and its next
feedtraps. That call returnsERR_POISONED, and so do all of the following:start,feed,encodeRequest,encodeReply,abandon,isWaiting) on both connections;nameHash,handshakeEncode,handshakeCheck,methodNameUnescape,connectionNew);supportsis 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 freshinstantiateSyncthen round-trips a request and reply.The same check runs with the trap planted on each connection call in turn.
A
RangeErrorfrom inside an export poisons.None of these poison, and the instance keeps working after them:
TypeErrorfrom the glue;RangeErrorfromBigInt(1.5);TypeErrorat the boundary (a BigInt where au16goes);Regions (
js/test/regions.test.mjs)Through raw exports, the test fills
inwith a pattern, then does the following throughoutonly:jsonrpc@1connections whose decode allocates (runtime#58), frees, and reserves ofoutat or below its size, which must return the same address;max_waiting = 4,000,000connection that grows memory.After that,
inis at the same address with the same bytes. A larger reserve may move it and keeps the contents, and growingindoesn't moveout.Through the glue, with counted exports:
encodeRequest,feed,encodeReply,isWaiting,abandon,nameHash,supportsandhandshakeEncodeare each exactly one crossing and no reserve. A 100 KB body reservesinandoutonce 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 withcapset 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",\,/, DEL, 2-, 3- and 4-byte UTF-8 and lone surrogates, as strings and as bytes, and sometimes nothing but\u00XXcontrols;u64::MAX, and at random over 64 bits;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::MAXthrough the ABI. The id digit count is the same function on both kinds, and replies cover every id up tou64::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):
datagram@2replydatagram@2requestjsonrpc@1replyjsonrpc@1requestThe exact bound has zero slack by construction, and the test asserts that.
Planted bugs. These must be caught, and are:
encodeBound(...) - 1fails all 200 cases;\u00XXescape at 5 bytes instead of 6 on the loose path is caught as well;\u00XXcontrols with noidmakes the loose bound equal*written, which proves the factor 6 is tight;JSONRPC_CLOSEby one fails the vector harness on everyjsonrpc@1case.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.
.wasmdatagram@2round tripjsonrpc@1round triprelease(before: opt 2, 16 units, no fat LTO)wasm, opt 2 (chosen)wasm, opt 3wasm, opt "s"wasm, opt "z".wasm, the new glue's round trip is about 30% faster than the old glue's:datagram@24.9 µs against 6.7–7.5 µs,jsonrpc@16.2–6.7 µs against 8.4–9.2 µs. That is the cached regions.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 withtest-hooks: no warnings in the ffi crates. The one warning is the existingnon_local_definitionsinruntime/core(sabi_trait), unchanged.cargo fmt --checkon the touched crates: clean.cbindgen0.29.2 regeneratescomline.hunchanged.cargo build --release, the export, thenmake -C runtime/ffi/tests/c check CC=clang(static, dynamic, ASan + UBSan): 65 cases each.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.cargo +nightly miri test -p comline-runtime-ffi --features test-hooks: everything passed (27 rule tests, the vector replay, the differential suite; the existingjsonrpc_decode_allocates_nothingstays ignored for runtime#58).cargo +nightly fuzz run --fuzz-dir runtime/fuzz <target> -- -max_total_time=180):ffi_feed592,422 runs,ffi_vs_core682,106 runs, no crashes.