diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a2d7cf6bc2..14659ff29a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -97,7 +97,7 @@ jobs: recipe: pub check-bbb uncached: true - name: Browser backend baseline - recipe: test wasm-baseline + recipe: test wasm-baseline --uniffi chromium: true artifact: wasm-backend-baseline artifact-path: target/test diff --git a/bench/ffi-gen/README.md b/bench/ffi-gen/README.md index 419e4280a5..71d33e1f74 100644 --- a/bench/ffi-gen/README.md +++ b/bench/ffi-gen/README.md @@ -131,8 +131,8 @@ BoltFFI's JS glue is its generated web module plus `@boltffi/runtime`, unminifie `.github/workflows/platform.yml` (`macos-latest`, beside `just swift check`). The harness has no Swift runner yet. - **UniFFI's JS glue.** ubrn, UniFFI's JS generator, does not support 0.32 yet, so only - its `.wasm` is sized. Browser per-call cost is - [#5478](https://github.com/moq-dev/moq/pull/5478)'s job. + its `.wasm` is sized. The browser comparison, including its per-call cost, is + [`test/wasm/generator.md`](/test/wasm/generator.md). - **The full moq-ffi through BoltFFI.** BoltFFI cannot bind moq-ffi without the API changes listed below, so its numbers come from the slice alone. - Go, Dart, C++, and C, which BoltFFI lacks or has only in part. Startup and memory. @@ -233,4 +233,5 @@ bulk copies), not from its core. A UniFFI fork can adopt each one backend at a t using these numbers as targets, while keeping all eight targets working throughout. Pick BoltFFI instead if call overhead outweighs everything else and writing the Go and -C++ backends is acceptable. The browser half is decided separately in #5478. +C++ backends is acceptable. The browser half was decided separately: UniFFI over ubrn +([#5838](https://github.com/moq-dev/moq/pull/5838), [`test/wasm/generator.md`](/test/wasm/generator.md)). diff --git a/bun.lock b/bun.lock index bba3137904..6fb235739a 100644 --- a/bun.lock +++ b/bun.lock @@ -489,6 +489,8 @@ "@moq/wasm": "workspace:*", }, "devDependencies": { + "@ubjs/core": "0.31.0-6", + "@ubjs/wasm": "0.31.0-6", "playwright": "^1.63.0", }, }, @@ -1076,6 +1078,10 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@ubjs/core": ["@ubjs/core@0.31.0-6", "", {}, "sha512-pscv6Zj8mVmd5+ikb1Zr6XZ0yqYwKGeIV8HU8rf9sTvs7GW34OARAp03xFJ7rE9XE4R6LgwQhzYW9knAuz5yxA=="], + + "@ubjs/wasm": ["@ubjs/wasm@0.31.0-6", "", { "peerDependencies": { "@ubjs/core": "^0.31.0-6" } }, "sha512-ja7pEcA4bCfEyFR8/zxivypUhhudr9n45lwcMR2r9U5hyTGkPnNnp1Ef5NbpTQieJd8wDkumV8Z0Yn0T0JEPAg=="], + "@ungap/global-this": ["@ungap/global-this@0.4.4", "", {}, "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], diff --git a/quest/m1/README.md b/quest/m1/README.md index b66b5446a4..666ef59f5d 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -110,6 +110,7 @@ lands before the cut. - [Close tail drain](/quest/m1/close-tail-drain.md) - after a peer close, subscribers read the group streams their transport already delivered instead of aborting them, so a finished tail arrives on every version - [Publisher linger](/quest/m1/publisher-linger.md) - every finishing publisher keeps its broadcast up for the media max-age so subscribers can drain it - [Exporters follow a rendition to a new track](/quest/m1/export-rendition-rebind.md) - the fMP4, TS, MKV and FLV exporters rebuild a rendition whose resolved `track` or `broadcast` changes under the same ID +- [lite-06 max sequence](/quest/m1/lite06-max-sequence.md) - group sequence 2^62-1 reaches a lite-06 subscriber or is refused loudly, instead of vanishing - [C++ and OBS package proof](/quest/m1/cpp-package-check.md) - the installed artifacts pass before their first release diff --git a/quest/m1/lite06-max-sequence.md b/quest/m1/lite06-max-sequence.md new file mode 100644 index 0000000000..af14b71b8f --- /dev/null +++ b/quest/m1/lite06-max-sequence.md @@ -0,0 +1,23 @@ +# [S] lite-06 drops group sequence 2^62-1 silently + +## Goal + +A group whose sequence is 2^62-1, the largest QUIC varint, either reaches a moq-lite-06 +subscriber through the relay or is refused loudly where it cannot be carried. Today the +publisher accepts it and the subscriber waits forever, while moq-lite-02 and +moq-transport-19 deliver it and 2^53+1 crosses all three. + +## Plan + +Found by the browser binding spike (#5838): `test/wasm/uniffi/cases.ts` publishes the +ladder 5, 2^32, 2^53-1, 2^53+1, 2^62-1 from one moq-ffi session to another through each +relay flavour. On lite-06 it records 2^62-1 as a known gap, and the case fails once the +gap closes. + +Reproduce natively first, with a moq-net test over the memory transport and then through +moq-relay, to find whether the publisher, the relay, or the subscriber drops it. Fail +loud if the value is illegal on lite-06; otherwise deliver it. Land a regression test +with the fix, then remove the browser case's known-gap branch. + +Public API: none expected. Wire: none if the value is legal; otherwise the refusal +matches the published draft. diff --git a/quest/m2/ffi-bytes.md b/quest/m2/ffi-bytes.md index 2d1e205c66..0a7e7c4a3d 100644 --- a/quest/m2/ffi-bytes.md +++ b/quest/m2/ffi-bytes.md @@ -11,6 +11,10 @@ wrapper (Python, Go, Kotlin, Swift, Dart, and C++). Decided with the maintainer on 2026-10-13: +- **The browser too.** Since 2026-10-17 (#5838) the browser binds moq-ffi through + UniFFI over ubrn, and pays the same per-byte path: about 7 ns per byte written and + 8 ns read at 128 KiB in Chromium, against BoltFFI's 0.5 and 1.3. Re-measure with + `test/wasm/uniffi/cases.ts`'s per-call timings once the fork's core changes. - **Benchmark first.** `just bench-ffi` ([bench/README.md](/bench/README.md)) measures every wrapper before and after. Add a Rust microbenchmark of UniFFI's lift and lower for `Vec` here, beside the patch it proves. diff --git a/quest/m2/rs2ts/README.md b/quest/m2/rs2ts/README.md index 9897427f7f..2dc132db84 100644 --- a/quest/m2/rs2ts/README.md +++ b/quest/m2/rs2ts/README.md @@ -44,6 +44,13 @@ for a source change. A batched boundary is an optimization candidate, not a reason to require a full sans-IO rewrite. Controlled time remains independently useful; source-level `Instant` mappings depend on the chosen backend. +Decided with the maintainer on 2026-10-17 +([#5838](https://github.com/moq-dev/moq/pull/5838)): the WASM binding is moq-ffi +generated by UniFFI over ubrn's `wasm2` player. It won over hand-written moq-wasm and +BoltFFI on maintenance (one surface for every language, no API break) and parity. Its +measured costs, size and payload copies, have their own quests. The report is +[`test/wasm/generator.md`](/test/wasm/generator.md). + Evidence from [#5337](https://github.com/moq-dev/moq/issues/5337) motivates reopening the options, not choosing a winner. Its attachment reports the original async Rust consumer passing nine browser tests in both WASM and @@ -61,8 +68,9 @@ any necessary breaks. Wire: no changes intended. ## Required -- [Binding generator](/quest/m2/rs2ts/generator.md) - the maintainer picks UniFFI- or BoltFFI-generated moq-ffi, or hand-written moq-wasm, from measurements - [Complete WASM bindings](/quest/m2/rs2ts/wasm-bindings.md) - Rust publishing and consumption cover the browser protocol and model surface +- [Net-only moq-ffi](/quest/m2/rs2ts/net-only.md) - the browser `.wasm` drops the media layer, near moq-wasm's size +- [ubrn reads UniFFI 0.32](/quest/m2/rs2ts/ubrn-032.md) - waiting on upstream, so the browser TypeScript generates without the measurement pin - [Adopt WASM in @moq/net](/quest/m2/rs2ts/wasm-adopt.md) - the TypeScript interface uses Rust, with measured tradeoffs accepted before replacement ## Related diff --git a/quest/m2/rs2ts/generator.md b/quest/m2/rs2ts/generator.md deleted file mode 100644 index d0834584b9..0000000000 --- a/quest/m2/rs2ts/generator.md +++ /dev/null @@ -1,103 +0,0 @@ -# [M] Choose the browser binding generator - -## Goal - -A reproducible report compares three Rust browser bindings on the baseline -harness: TypeScript that UniFFI generates from `rs/moq-ffi`, TypeScript -that BoltFFI generates from it, and the hand-written wasm-bindgen -`rs/moq-wasm`. The maintainer picks which one `@moq/net` wraps, and the -report records that decision. This quest does not adopt any binding, -delete `moq-wasm`, or evaluate JS-only output. - -## Plan - -Decided with the maintainer on 2026-10-13: - -- Spike before [Complete WASM bindings](/quest/m2/rs2ts/wasm-bindings.md) - grows `moq-wasm`. Every native language already binds through `moq-ffi`. - [#2907](https://github.com/moq-dev/moq/issues/2907) argued the browser - should too, rather than keep a second hand-written surface. -- Bind `moq-ffi` as it is, not a browser-only UniFFI crate: - [#5460](https://github.com/moq-dev/moq/pull/5460) rejected per-layer crate - splits. The generated TS tree-shakes, but the `.wasm` does not. The linker - keeps every UniFFI export, so only cargo features reduce size. Toggling - `default-features` changes nothing on wasm32 (`audio` and `video` are - already native-only), so report each module's share of the `.wasm` - instead. If size decides the result, propose a net-only feature rather - than a new crate. -- Use [uniffi-bindgen-react-native](https://github.com/jhugman/uniffi-bindgen-react-native) - (ubrn), whose `wasm2` flavour is a plain wasm32 cargo build driven by a - generic JS player. Upstream UniFFI will not ship a JS generator and points - to ubrn. ubrn pins UniFFI 0.31, while `moq-ffi` is on 0.32. The spike - branch therefore pins `moq-ffi` to 0.31 for measurement only and does not - land that pin. `wasm2` also needs the matching `uniffi-runtime-wasm` - dependency and an `extern crate uniffi_runtime_wasm as _` to force its - link (the player looks for `__ubrn_alloc`). Add both on the spike branch - and count the runtime in the shipped size. If UniFFI wins, add a waiting quest for ubrn 0.32 support - ([#468](https://github.com/jhugman/uniffi-bindgen-react-native/pull/468)), - required by adoption but not by this report. -- [BoltFFI](https://github.com/boltffi/boltffi) is the third candidate - (decided in [#5498](https://github.com/moq-dev/moq/pull/5498), whose - BoltFFI spike covers the native half). Neither quest blocks the other. - BoltFFI has its own macros, so it cannot bind `moq-ffi` as-is. Reuse - that spike's slice crate, [`bench/ffi-gen`](/bench/ffi-gen/README.md), - and measure UniFFI on the same slice so the per-call numbers compare - like for like. It carries BoltFFI's markers literally, with a shim crate - mapping them to UniFFI's, because BoltFFI's scanner does not see - `cfg_attr`. Its `run.sh build` already produces both `.wasm` files and - BoltFFI's web package; ubrn has no 0.32 support, so no UniFFI JS. Full `moq-ffi` through UniFFI still provides the size and - coverage numbers. The slice is a feasibility filter for BoltFFI, not - selection evidence: if BoltFFI passes and is favored, port the harness - workload to it and rerun the parity, size, startup, and memory cases - before the maintainer picks it. BoltFFI ships its own wasm loader and - `.d.ts` files for web, node, and bundlers. Async uses poll plus a - continuation, like UniFFI. Crates using `web-sys` trapped at runtime - until [boltffi#943](https://github.com/boltffi/boltffi/pull/943) - (2026-09-28, released in v0.31.0). Async constructors bind in v0.31.1, - though [boltffi#95](https://github.com/boltffi/boltffi/issues/95) is - still open; TS types the slice's `MoqSession.dial` as - `Promise`. TypeScript streams are experimental - ([boltffi#40](https://github.com/boltffi/boltffi/issues/40)). Roughly - one maintainer. - -What already exists: `moq-ffi` compiles for wasm32 -([#2911](https://github.com/moq-dev/moq/pull/2911)) with -`wasm-unstable-single-threaded`, including the media layer. Native codecs, -the server, and the TLS knobs are gated out. The #2907 spike generated TS -(with a different generator) that passes `tsc --strict`. It maps u64 to -bigint and lets a handle be freed while one of its calls is pending. -None of it has run in a browser. - -Measure on the [baseline harness](/test/wasm/README.md#backend-baseline): - -- Shipped size (compressed `.wasm` plus JS player and glue), cold start, - first frame, per-frame CPU, and memory. -- Per-async-call cost, since UniFFI resolves one JS Promise per - `rust_future_poll` wakeup. Compare it with wasm-bindgen's async glue. - Sweep publisher and subscriber counts. -- Parity on the harness cases: exact u64 above 2^53, cancellation through - `AbortSignal`, close/error propagation, and freeing a handle mid-call. -- `ffi::Task::run` awaits in place on every target: a cancelled foreign - call stops polling, and its future drops at `rust_future_free`. Test that - aborting or freeing a pending call releases its state without stealing - the next call's event. -- Things the binding cannot do. `JsValue` cannot cross the boundary, so - Rust constructs the WebTransport, as `moq-ffi` already does. Cover the - supported fallback transport as well. -- Deployment: ubrn's player compiles dispatchers with `new Function` and - falls back to an interpreter under a strict CSP. Measure both modes. Note - open ubrn wasm issues such as TS that fails to type-check - ([#472](https://github.com/jhugman/uniffi-bindgen-react-native/issues/472)). -- The JS-only path: whether the player can drive wasm2js output, as an - input to the [WASM-to-JS evaluation](/quest/m2/rs2ts-wasm2js.md). - -There are no invented thresholds; the maintainer weighs the report. Update -the wasm-bindings quest for the chosen binding. If a generated binding -wins, that quest completes `moq-ffi`'s browser parity and generates the TS in CI, and -`moq-wasm`'s deletion moves to [adoption](/quest/m2/rs2ts/wasm-adopt.md). - -Public API: none. Wire: none. - -## Related - -- [Binding namespaces](/quest/m1/ffi-shape/README.md) - the `moq-ffi` shape a generated browser binding inherits diff --git a/quest/m2/rs2ts/net-only.md b/quest/m2/rs2ts/net-only.md new file mode 100644 index 0000000000..4a8ea277ac --- /dev/null +++ b/quest/m2/rs2ts/net-only.md @@ -0,0 +1,30 @@ +# [M] A net-only moq-ffi build for the browser + +## Goal + +The browser's moq-ffi `.wasm` carries only the network surface `@moq/net` wraps, so its +shipped size lands near moq-wasm's (about 740 KB gzip on 2026-10-16) instead of the full +build's 1.70 MB, stripped. Measured with `bun test/wasm/uniffi/size.ts` and the +baseline sweep's asset sizes. + +## Plan + +Decided with the maintainer on 2026-10-17 (#5838): the browser binds moq-ffi through +UniFFI, and size was the choice's largest cost. The report +(`test/wasm/generator.md`) attributes about 30% of moq-ffi's code to the media layer: +mp4\_atom, moq\_mux, `hang`'s regex crates, and the JSON crates. A slice that consumes and +publishes over WebTransport landed within 3% of moq-wasm under UniFFI, so the generator +itself adds little. + +Use a cargo feature on moq-ffi, not a new crate: #5460 rejected per-layer crate splits. +The UniFFI exports the linker keeps are the ones the feature compiles in. Toggling +`default-features` changes nothing on wasm32, because `audio` and `video` are already +native-only. Also strip the `name` section when staging, worth about 235 KB gzip. + +Public API: a new moq-ffi cargo feature; the bound surface is unchanged where it is +enabled. Wire: none. + +## Related + +- [Adopt WASM in @moq/net](/quest/m2/rs2ts/wasm-adopt.md) - weighs the shipped size +- [Bindings size profile](/quest/m2/ffi-size-profile.md) - the native builds' size question diff --git a/quest/m2/rs2ts/ubrn-032.md b/quest/m2/rs2ts/ubrn-032.md new file mode 100644 index 0000000000..a2c5f891de --- /dev/null +++ b/quest/m2/rs2ts/ubrn-032.md @@ -0,0 +1,30 @@ +# [XS] Waiting: ubrn generates UniFFI 0.32 TypeScript + +## Goal + +uniffi-bindgen-react-native (ubrn) ships a release whose `wasm2` flavour reads UniFFI +0.32 metadata, so moq-ffi's browser TypeScript generates from the published crate +without `test/wasm/uniffi/pin.patch`. Check: whether +[ubrn#468](https://github.com/jhugman/uniffi-bindgen-react-native/pull/468) has merged and +a release after 0.31.0-6 lists it. Delete this quest once the pin is gone. + +## Plan + +Decided with the maintainer on 2026-10-17 (#5838): the browser binds moq-ffi through +UniFFI over ubrn's `wasm2` player. ubrn pins UniFFI 0.31; moq-ffi is on 0.32, and the +harness pins a scratch checkout to 0.31 for measurement only. + +If ubrn has not moved by the time adoption is otherwise ready, carry ubrn's TypeScript +generator in [the UniFFI fork](/quest/m2/uniffi-fork.md) instead, and delete this quest. + +When raising it upstream, also report two costs the spike measured. Posting outside +`moq-dev` and `kixelated` needs the maintainer's approval. + +- `@ubjs/core` arms and clears a `setTimeout` keep-alive on every async call, which only + Node.js needs. +- The generated `*Like` return types omit `uniffiDestroy`, so freeing a returned handle + early needs a cast to the class. + +## Related + +- [Adopt WASM in @moq/net](/quest/m2/rs2ts/wasm-adopt.md) - ships the generated TypeScript this unblocks diff --git a/quest/m2/rs2ts/wasm-adopt.md b/quest/m2/rs2ts/wasm-adopt.md index d8736adb5e..8d747fd269 100644 --- a/quest/m2/rs2ts/wasm-adopt.md +++ b/quest/m2/rs2ts/wasm-adopt.md @@ -30,6 +30,9 @@ Update watch, publish, hang, room, demos, and other affected consumers together. in existing API docs and package READMEs, together with regeneration. - Retain baseline revisions and shared tests after removing the handwritten implementation. Keep the WASM backend while the JS-only stage is evaluated. +- Delete `rs/moq-wasm` with the handwritten code: on 2026-10-17 (#5838) the + maintainer chose UniFFI-generated moq-ffi as the browser binding, so moq-wasm + is only the baseline's reference until then. Public API: `@moq/net` remains the package; enumerate any approved signature, initialization, lifetime, or deployment changes. Wire: none. @@ -37,3 +40,5 @@ initialization, lifetime, or deployment changes. Wire: none. ## Required - [Complete WASM bindings](/quest/m2/rs2ts/wasm-bindings.md) - the complete Rust-backed candidate and parity coverage +- [ubrn reads UniFFI 0.32](/quest/m2/rs2ts/ubrn-032.md) - the TypeScript generates from the published moq-ffi, without the measurement pin +- [Net-only moq-ffi](/quest/m2/rs2ts/net-only.md) - the browser `.wasm` drops the media layer, near moq-wasm's size diff --git a/quest/m2/rs2ts/wasm-bindings.md b/quest/m2/rs2ts/wasm-bindings.md index 7c4a0b0325..e4a0e77905 100644 --- a/quest/m2/rs2ts/wasm-bindings.md +++ b/quest/m2/rs2ts/wasm-bindings.md @@ -16,8 +16,18 @@ close/error behavior, cancellation, and backpressure. Validate supported versions and transports with the browser fixtures and `just test interop --all`. Wire new tests into CI, at least nightly. -Complete the binding the [generator quest](/quest/m2/rs2ts/generator.md) -chose: `moq-ffi` through UniFFI or BoltFFI, or the consume-only `moq-wasm`. Measure boundary calls, Promise allocation, +Decided with the maintainer on 2026-10-17 (#5838, report in +[`test/wasm/generator.md`](/test/wasm/generator.md)): complete `moq-ffi` through UniFFI +over ubrn's `wasm2` player, not the hand-written `moq-wasm` and not BoltFFI. This quest +completes moq-ffi's browser parity and generates its TypeScript in CI, extending +`test/wasm/uniffi/` (`just test wasm-baseline --uniffi`, nightly). `moq-wasm`'s deletion +moves to [adoption](/quest/m2/rs2ts/wasm-adopt.md). Until +[ubrn reads UniFFI 0.32](/quest/m2/rs2ts/ubrn-032.md), generation keeps applying +`test/wasm/uniffi/pin.patch` to a scratch checkout. + +Gaps the spike found against `@moq/net` and moq-wasm: moq-ffi exposes no negotiated +version and has no WebSocket fallback on wasm, and payload copies cost about 7 to 8 ns +per byte (see [Payload bytes](/quest/m2/ffi-bytes.md)). Measure boundary calls, Promise allocation, payload copies, and retained memory. Compare a batched poll/event-drain boundary if these dominate, including latency and backpressure effects; do not require a sans-IO rewrite or impose batching latency to win throughput. @@ -34,7 +44,3 @@ Update the bindings' README and existing API docs for the surface added. Public API: extends the unpublished browser bindings; any changes needed to `@moq/net` are proposed in the adoption quest. Wire: none. - -## Required - -- [Binding generator](/quest/m2/rs2ts/generator.md) - which binding this completes diff --git a/test/tsconfig.json b/test/tsconfig.json index 10187d489f..5bd3f1db12 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -5,5 +5,5 @@ "noEmit": true }, "include": ["**/*", "../js/common/worklet.d.ts"], - "exclude": ["**/node_modules", "**/dist", "**/target"] + "exclude": ["**/node_modules", "**/dist", "**/target", "wasm/uniffi"] } diff --git a/test/wasm/README.md b/test/wasm/README.md index 0c7e79d94b..d072cee881 100644 --- a/test/wasm/README.md +++ b/test/wasm/README.md @@ -172,3 +172,15 @@ WASM-to-JS measurements are separate, unverified results here. Full network sequences above 2^53 need a native publisher and a JS model that can represent them before adoption. WASM frame timestamps and complete publish/backpressure API parity remain explicit gaps too. + +## Generated bindings + +```bash +just test wasm-baseline --uniffi +``` + +`--uniffi` adds moq-ffi, bound by UniFFI over ubrn's `wasm2` player, as a third +baseline backend. It also runs parity cases and per-call timings in `uniffi/` against +every relay, once normally and once under a strict CSP. It builds from a scratch +checkout, since ubrn needs a UniFFI pin that does not land. +[generator.md](generator.md) is the report that chose this binding. diff --git a/test/wasm/baseline/consumer.ts b/test/wasm/baseline/consumer.ts index 4e1fe79448..0ff9b378e3 100644 --- a/test/wasm/baseline/consumer.ts +++ b/test/wasm/baseline/consumer.ts @@ -2,6 +2,31 @@ import { Milli } from "@moq/clock"; import type * as Moq from "@moq/net"; import type * as Wasm from "@moq/wasm"; import { type Sample, verify } from "./fixture.ts"; +import type { Uniffi } from "./uniffi.ts"; + +/** The relay's self-signed certificate hash, and its URL rewritten for WebTransport. */ +async function pin(relay: string): Promise<{ url: URL; hex: string }> { + const url = new URL(relay); + const response = await fetch(new URL("/certificate.sha256", url)); + if (!response.ok) throw new Error(`certificate HTTP ${response.status}`); + const hex = (await response.text()).trim(); + if (!/^[a-fA-F0-9]{64}$/.test(hex)) throw new Error("malformed certificate hash"); + url.protocol = "https:"; + return { url, hex }; +} + +/** Record the next instantiated module's memory; ubrn's player keeps its instance private. */ +function captureMemory(): () => WebAssembly.Memory | undefined { + let memory: WebAssembly.Memory | undefined; + const original = WebAssembly.instantiate; + WebAssembly.instantiate = (async (...args: Parameters) => { + const result = (await original(...args)) as WebAssembly.Instance | WebAssembly.WebAssemblyInstantiatedSource; + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + memory ??= Object.values(instance.exports).find((value) => value instanceof WebAssembly.Memory); + return result; + }) as typeof original; + return () => memory; +} async function run(config: Sample) { const start = performance.now(); @@ -81,7 +106,7 @@ async function run(config: Sample) { }), ); }; - } else { + } else if (config.backend === "wasm") { if (config.transport !== "webtransport") throw new Error("current WASM bindings have no WebSocket fallback"); const wasm = (await import(config.module)) as typeof Wasm; @@ -89,13 +114,8 @@ async function run(config: Sample) { memory = exports.memory; wasm.setup(); startup = performance.now() - start; - const url = new URL(config.url); - const response = await fetch(new URL("/certificate.sha256", url)); - if (!response.ok) throw new Error(`certificate HTTP ${response.status}`); - const hex = (await response.text()).trim(); - if (!/^[a-fA-F0-9]{64}$/.test(hex)) throw new Error("malformed certificate hash"); + const { url, hex } = await pin(config.url); const hash = Uint8Array.from(hex.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16)); - url.protocol = "https:"; read = async () => { const session = await wasm.Session.connectWithHashes(url.toString(), [hash]); if (session.version() !== config.version) @@ -124,12 +144,60 @@ async function run(config: Sample) { }), ); }; + } else { + if (config.transport !== "webtransport") throw new Error("moq-ffi's wasm client has no WebSocket fallback"); + const memoryOf = captureMemory(); + const uniffi = (await import(config.module)) as Uniffi; + await uniffi.uniffiInitAsync(new URL("uniffi/moq_ffi.wasm", config.module)); + memory = memoryOf(); + startup = performance.now() - start; + const { url, hex } = await pin(config.url); + read = async () => { + const client = new uniffi.MoqClient( + uniffi.MoqClientConfig.create({ + // moq-ffi reports no negotiated version, so offering only the expected one checks it. + versions: [config.version], + tls: uniffi.MoqClientTls.create({ fingerprints: [hex] }), + }), + ); + const session = await client.connect(url.toString()); + disposers.push(() => session.cancel(0)); + await Promise.all( + Array.from({ length: config.publishers }, async (_, publisher) => { + const broadcast = await session + .consume() + .announcedBroadcast(`${config.id}/${publisher}`) + .available(); + const track = await broadcast.subscribeTrack("data", undefined); + disposers.push(() => track.cancel()); + const group = await track.recvGroup(); + if (!group || group.sequence() !== BigInt(config.sequence)) + throw new Error("incorrect UniFFI group sequence"); + try { + for (let frame = 0; frame < config.frames; frame++) { + const got = await group.readFrame(); + if ( + !got || + (config.version === "moq-lite-02" + ? got.timestampUs !== undefined + : got.timestampUs !== BigInt(frame * 1000)) + ) + throw new Error(`incorrect UniFFI frame timestamp ${frame}`); + check(new Uint8Array(got.payload), frame); + } + if (await group.readFrame()) throw new Error("unexpected extra UniFFI frame"); + } finally { + group.cancel(); + } + }), + ); + }; } const ready = performance.now(); const memoryBefore = memory?.buffer.byteLength; await Promise.all(Array.from({ length: config.subscribers }, (_, subscriber) => read(subscriber))); if (received !== frames) throw new Error(`received ${received} frames, expected ${frames}`); - if (config.grow && config.backend === "wasm" && !grew) throw new Error("memory-growth case did not run"); + if (config.grow && config.backend !== "js" && !grew) throw new Error("memory-growth case did not run"); return { backend: config.backend, transport: config.transport, @@ -145,9 +213,9 @@ async function run(config: Sample) { timestamp: config.version === "moq-lite-02" ? "verified absent: this wire version carries none" - : config.backend === "js" - ? "verified" - : "unavailable: payload-only binding", + : config.backend === "wasm" + ? "unavailable: payload-only binding" + : "verified", }, }; } finally { diff --git a/test/wasm/baseline/driver.ts b/test/wasm/baseline/driver.ts index e528b60033..c6815ea493 100644 --- a/test/wasm/baseline/driver.ts +++ b/test/wasm/baseline/driver.ts @@ -19,6 +19,9 @@ const dist = join(root, "test/wasm/dist"); const wasm = join(root, "js/wasm/dist"); const output = join(run, "backend.json"); const smoke = process.env.MOQ_WASM_BASELINE_SMOKE === "1"; +// moq-ffi over ubrn's wasm2 player, built by uniffi/build.sh; see README.md. +const uniffi = process.env.MOQ_WASM_UNIFFI === "1"; +const generated = join(root, "test/wasm/uniffi/dist"); const relays = (await Bun.file(values.relays).json()) as RelayFixture[]; if (!relays.length) throw new Error("no relay fixtures"); const server = Bun.serve({ @@ -30,7 +33,11 @@ const server = Bun.serve({ return new Response(``, { headers: { "content-type": "text/html", "cache-control": "no-store" }, }); - const file = path.startsWith("/wasm/") ? Bun.file(join(wasm, path.slice(6))) : Bun.file(join(dist, path)); + const file = path.startsWith("/wasm/") + ? Bun.file(join(wasm, path.slice(6))) + : path.startsWith("/uniffi/") + ? Bun.file(join(generated, path.slice(8))) + : Bun.file(join(dist, path)); return (await file.exists()) ? new Response(file, { headers: { "cache-control": "no-store" } }) : new Response("not found", { status: 404 }); @@ -68,6 +75,9 @@ const report = { revision: command("git", "rev-parse", "HEAD"), status: command("git", "status", "--porcelain"), features: "moq-wasm defaults; wasm-release; repository browser cfgs; bindgen web target; Binaryen not invoked", + uniffi: uniffi + ? "moq-ffi defaults pinned to UniFFI 0.31 (uniffi/pin.patch); wasm-release; ubrn 0.31.0-6 wasm2; bundled with bun --minify" + : "not run", }, tools: { rust: command("rustc", "--version"), @@ -86,6 +96,8 @@ const report = { assets: { js: [await asset(join(dist, "net.js"))], wasm: await Promise.all((await wasmAssets(wasm)).map(asset)), + // The bundled bindings, @ubjs runtime and wasm-bindgen glue, plus the staged module. + uniffi: uniffi ? [await asset(join(dist, "uniffi.js")), await asset(join(generated, "moq_ffi.wasm"))] : [], harness: [await asset(join(dist, "consumer.js")), await asset(join(dist, "publisher.js"))], }, limits: [ @@ -104,7 +116,13 @@ const report = { await Bun.write(output, JSON.stringify(report, null, 2)); const browser = await chromium.launch({ channel: "chromium", headless: true }); report.tools.browser = browser.version(); -const order = ["js", "js", "wasm", "wasm", "js", "wasm"] as const; +// Alternating and consecutive A/A rounds, so drift on a shared host hits every backend: +// three rounds each, in an order that puts every backend first, last, and twice in a row. +const backendsEnabled: Sample["backend"][] = ["js", "wasm", ...(uniffi ? (["uniffi"] as const) : [])]; +const order: Sample["backend"][] = + backendsEnabled.length === 2 + ? ["js", "js", "wasm", "wasm", "js", "wasm"] + : [...backendsEnabled, ...[...backendsEnabled].reverse(), ...backendsEnabled]; let serial = 0; try { for (const relay of smoke ? relays.slice(0, 1) : relays) { @@ -131,7 +149,10 @@ try { })), ]; for (const shape of smoke ? [shapes[0], shapes[13]] : shapes) { - for (const [round, backend] of (smoke ? (["js", "wasm"] as const) : order).entries()) { + const backends: Sample["backend"][] = smoke + ? ["js", "wasm", ...(uniffi ? (["uniffi"] as const) : [])] + : order; + for (const [round, backend] of backends.entries()) { const config: Sample = { ...shape, id: `baseline/${serial++}`, @@ -139,7 +160,7 @@ try { version: relay.version, backend, transport: "webtransport", - module: `${endpoint}/${backend === "js" ? "net.js" : "wasm/moq.js"}`, + module: `${endpoint}/${{ js: "net.js", wasm: "wasm/moq.js", uniffi: "uniffi.js" }[backend]}`, }; await sample(config, round); } diff --git a/test/wasm/baseline/fixture.ts b/test/wasm/baseline/fixture.ts index a27d1f8713..c3763a7d4d 100644 --- a/test/wasm/baseline/fixture.ts +++ b/test/wasm/baseline/fixture.ts @@ -11,7 +11,7 @@ export interface Workload { } export interface Sample extends Workload { - backend: "js" | "wasm"; + backend: "js" | "wasm" | "uniffi"; transport: "webtransport" | "websocket"; module: string; grow: boolean; diff --git a/test/wasm/baseline/uniffi.ts b/test/wasm/baseline/uniffi.ts new file mode 100644 index 0000000000..8cc3dc01a1 --- /dev/null +++ b/test/wasm/baseline/uniffi.ts @@ -0,0 +1,32 @@ +/** + * The part of the ubrn-generated moq-ffi bindings the baseline consumer reads through. + * + * Declared here rather than imported, because the generated module only exists after + * `uniffi/build.sh`. `uniffi/check.ts` proves the generated module satisfies it. + */ +export interface Uniffi { + uniffiInitAsync(source: URL): Promise; + MoqClient: new (config: Config) => { connect(url: string): Promise }; + MoqClientConfig: { create(fields: { versions: string[]; tls: Tls }): Config }; + MoqClientTls: { create(fields: { fingerprints: string[] }): Tls }; +} + +interface Session { + consume(): { announcedBroadcast(path: string): { available(): Promise } }; + cancel(code: number): void; +} + +interface Broadcast { + subscribeTrack(name: string, subscription: undefined): Promise; +} + +interface Track { + recvGroup(): Promise; + cancel(): void; +} + +interface Group { + sequence(): bigint; + readFrame(): Promise<{ payload: ArrayBuffer; timestampUs?: bigint } | undefined>; + cancel(): void; +} diff --git a/test/wasm/generator.md b/test/wasm/generator.md new file mode 100644 index 0000000000..49b7523d75 --- /dev/null +++ b/test/wasm/generator.md @@ -0,0 +1,255 @@ +# Browser binding generator report + +Which Rust binding `@moq/net` wraps in the browser: moq-ffi through UniFFI (ubrn's +`wasm2` player), moq-ffi through BoltFFI, or the hand-written wasm-bindgen `rs/moq-wasm`. +This compares them on the [baseline harness](README.md#backend-baseline). + +BoltFFI cannot bind moq-ffi as written (see [What blocks BoltFFI](#what-blocks-boltffi)), +so its network numbers come from the bench/ffi-gen slice with a browser client added. The +same slice also runs through UniFFI, so the two generators compare on identical Rust. + +**Decision** (maintainer, 2026-10-17, [#5838](https://github.com/moq-dev/moq/pull/5838)): +moq-ffi through UniFFI over ubrn. It keeps one binding surface for every language with no +API break, and passes every parity case. Its two measured costs have follow-ups: +[size](/quest/m2/rs2ts/net-only.md) and [payload copies](/quest/m2/ffi-bytes.md). +[Complete WASM bindings](/quest/m2/rs2ts/wasm-bindings.md) builds on this harness. + +## Reproduce + +```bash +just test wasm-baseline --uniffi # JS, moq-wasm, and moq-ffi in the baseline sweep, plus parity cases and per-call timings +just test wasm --uniffi # the parity cases and per-call timings alone +bun test/wasm/uniffi/size.ts ... # sections and per-crate code share +``` + +ubrn 0.31.0-6 reads UniFFI 0.31 metadata and moq-ffi is on 0.32, so +[`uniffi/build.sh`](uniffi/build.sh) applies [`uniffi/pin.patch`](uniffi/pin.patch) to a +scratch checkout. The pin is measurement-only and does not land; moq-ffi built on 0.31 with +no source change beyond linking ubrn's runtime. + +The slice columns came from a harness removed once UniFFI was chosen. It is preserved at +[`9f557ac2b`](https://github.com/moq-dev/moq/tree/9f557ac2ba274508b3ad3b9f6910cc0861632b6f/test/wasm/slice) +(`just test wasm-baseline --uniffi --slice` there). It copied `bench/ffi-gen` from +[#5736](https://github.com/moq-dev/moq/pull/5736) and applied `slice/browser.patch`, a +`browser` feature with a WebTransport session that consumes and publishes like moq-ffi's +wasm client. Then it bound the result with both generators. + +## Setup + +Measured 2026-10-16 at `3789ee2ef` on an AMD Ryzen 9 5900XT (32 threads), Linux, headless +Chromium 153.0.8010.12. The host was shared with other agents (load average 12 to 32), so +treat absolute numbers as noisy and compare within a table. Every comparison interleaves +its candidates. The sweep runs each shape three times per backend in the order js, wasm, +uniffi, slice-uniffi, slice-boltffi, then reversed, then forward again. The per-call bench +runs ABBA rounds on fresh pages. Cells are medians with the min to max range. + +All builds use `wasm-release` (opt-level z, fat LTO) and no wasm-opt, as moq-wasm ships. + +## Size + +Shipped assets, as each backend's page loads them. gzip is Bun's default level. + +| backend | `.wasm` | `.wasm` gzip | JS | JS gzip | total gzip | total gzip, no custom sections | +|---|---|---|---|---|---|---| +| `@moq/net` (JS) | - | - | 386,552 | 109,486 | 109,486 | 109,486 | +| moq-wasm, wasm-bindgen | 2,194,698 | 730,348 | 39,052 | 7,782 | 738,130 | 737,871 | +| moq-ffi, UniFFI | 6,408,963 | 1,903,175 | 239,899 | 34,751 | 1,937,926 | 1,702,676 | +| slice, UniFFI | 4,527,125 | 953,899 | 78,671 | 18,675 | 972,574 | 747,969 | +| slice, BoltFFI | 4,426,230 | 924,626 | 65,177 | 14,705 | 939,331 | 721,303 | + +The JS is the generated bindings plus each generator's runtime, minified: `@ubjs/core`, +`@ubjs/wasm`, and wasm-bindgen's glue for UniFFI, `@boltffi/runtime` for BoltFFI. ubrn +and BoltFFI both stage the module with its `name` section, which wasm-bindgen drops for +moq-wasm; stripping it is worth about 230 KB gzip. + +The slice, which consumes and publishes over WebTransport, lands within 3% of moq-wasm +stripped under either generator. Stripped, BoltFFI is 27 KB gzip smaller than UniFFI on +the same Rust. moq-ffi's size is its surface, not its generator. Its code section (3.79 MB) by +crate, attributing a generic to the crate its path starts with: + +| crate | share | crate | share | +|---|---|---|---| +| moq\_net | 25.6% | regex\_automata + regex\_syntax + aho\_corasick | 8.6% | +| alloc | 9.5% | UniFFI exports + uniffi\_core | 7.1% | +| mp4\_atom | 8.8% | moq\_json + serde\_json | 4.8% | +| core | 8.4% | moq\_ffi | 1.9% | +| moq\_mux | 8.0% | everything else | 17.3% | + +The regex crates come from `hang`. mp4\_atom, moq\_mux, the regex crates, and the JSON +crates are about 30% of the code and serve the media layer; a net-only cargo feature could +drop most of them. Toggling `default-features` changes nothing on wasm32. + +## Startup, CPU, and memory + +The baseline sweep: the JS publisher page serves N broadcasts, and the measured page +consumes them with each backend. CPU is the consumer renderer's CDP thread time, including +connection setup, protocol, boundary copies, and byte checks, per frame received. +32 frames per subscriber. This sweep ran at load average 29 to 32. + +| | JS | moq-wasm | moq-ffi, UniFFI | slice, UniFFI | slice, BoltFFI | +|---|---|---|---|---|---| +| module import and init, ms | 48 (28-94) | 39 (20-74) | 92 (64-178) | 50 (35-79) | 35 (27-106) | +| first frame, ms | 120 (82-215) | 134 (70-234) | 180 (116-332) | 126 (91-223) | 92 (72-267) | +| wasm memory before reads, MiB | - | 1.31 | 1.81 | 1.38 | 1.25 | +| JS heap after reads, MiB | 3.43 | 2.06 | 3.65 | 2.48 | 2.15 | + +Startup rows are the 160 B 1x1 shape on all three relays. + +CPU per frame, ms, publishers x subscribers: + +| protocol, shape | JS | moq-wasm | moq-ffi, UniFFI | slice, UniFFI | slice, BoltFFI | +|---|---|---|---|---|---| +| lite-06, 160 B, 1x1 | 2.84 (2.58-2.90) | 3.96 (3.90-4.39) | 5.79 (5.56-5.98) | 4.28 (3.91-5.22) | 3.75 (3.75-4.32) | +| lite-06, 160 B, 4x4 | 0.39 (0.36-0.41) | 0.47 (0.43-0.53) | 0.58 (0.44-0.60) | 0.52 (0.38-0.54) | 0.43 (0.37-0.48) | +| lite-06, 4 KiB, 1x4 | 1.22 (1.13-1.23) | 1.53 (1.39-1.54) | 2.01 (1.83-2.06) | 1.97 (1.81-2.09) | 1.73 (1.38-1.85) | +| lite-06, 128 KiB, 1x1 | 2.03 (1.98-2.11) | 3.07 (2.95-3.23) | 4.45 (4.36-4.71) | 4.17 (3.73-4.19) | 3.26 (3.20-3.29) | +| lite-06, 128 KiB, 1x4 | 0.98 (0.97-1.08) | 1.35 (1.28-1.42) | 2.31 (2.24-2.33) | 2.34 (2.12-2.37) | 1.45 (1.41-1.57) | +| lite-06, 128 KiB, 4x4 | 1.26 (0.82-1.29) | 1.48 (1.06-1.58) | 2.17 (1.54-2.50) | 1.70 (1.61-2.08) | 1.11 (1.02-1.81) | +| transport-19, 160 B, 1x1 | 2.21 (1.67-2.43) | 2.92 (2.53-3.65) | 4.69 (3.28-4.79) | 3.28 (2.99-3.92) | 2.58 (2.45-3.92) | +| transport-19, 128 KiB, 1x4 | 1.77 (1.52-1.96) | 2.44 (2.33-2.69) | 4.19 (3.58-4.26) | 4.13 (2.94-4.66) | 2.47 (2.19-3.39) | +| lite-02, 160 B, 4x1 | 0.51 (0.49-0.52) | 0.64 (0.62-0.68) | 0.90 (0.87-0.91) | 0.75 (0.67-1.04) | 0.61 (0.58-0.65) | +| lite-02, 128 KiB, 1x4 | 1.25 (1.22-1.26) | 1.99 (1.61-2.03) | 3.44 (2.37-3.71) | 3.66 (2.46-3.91) | 2.15 (1.48-3.02) | +| lite-02, 128 KiB, 4x4 | 0.87 (0.85-1.12) | 1.11 (1.05-1.63) | 1.54 (1.53-1.62) | 1.53 (1.46-1.74) | 1.08 (1.00-1.08) | + +Geometric means of the per-shape median ratios, over all 45 WebTransport shapes: + +| ratio | all | 160 B and 4 KiB | 128 KiB | +|---|---|---|---| +| slice: BoltFFI / UniFFI | 0.86 | 0.90 | 0.75 | +| slice, BoltFFI / moq-wasm | 1.01 | 0.99 | 1.05 | +| slice, UniFFI / moq-wasm | 1.18 | 1.10 | 1.41 | +| moq-ffi, UniFFI / moq-wasm | 1.34 | 1.28 | 1.48 | +| moq-ffi, UniFFI / slice, UniFFI | 1.13 | 1.17 | 1.05 | + +On identical Rust, BoltFFI costs 10% less CPU per frame than UniFFI for small frames and +25% less for 128 KiB ones, where UniFFI copies each payload byte by byte. Through BoltFFI, +the slice matches moq-wasm. The last row is moq-ffi's own bookkeeping over the slice's, not +the generator's. The raw samples for every shape, including the u32 and near-2^53 +sequences, are in the run's `backend.json`. + +## Per-call cost + +Local calls, no network, so only the binding and moq-net's model count. Microseconds per +call, from one session at load average 12 to 20. + +The slice, six ABBA rounds: + +| call | UniFFI | BoltFFI | +|---|---|---| +| sync `noop()` | 0.57 (0.38-0.68) | 0.03 (0.02-0.05) | +| sync getter `sequence()` | 0.40 (0.35-0.55) | 0.04 (0.01-0.07) | +| async `noop_async()` | 3.07 (2.82-4.18) | 1.44 (1.10-1.98) | +| async `yield_once()`: one wakeup | 3.71 (3.16-5.28) | 2.60 (2.16-2.80) | +| `write_frame` 160 B | 2.40 (2.23-3.07) | 0.85 (0.79-0.89) | +| `write_frame` 4 KiB | 29.2 (27.9-30.9) | 2.58 (2.45-2.93) | +| `write_frame` 128 KiB | 894 (854-936) | 65.3 (62.5-70.5) | +| `read_frame`, buffered, 160 B | 6.27 (5.81-7.20) | 2.87 (2.51-4.00) | +| `read_frame`, buffered, 4 KiB | 39.6 (37.5-40.8) | 9.56 (9.05-10.6) | +| `read_frame`, buffered, 128 KiB | 1,079 (1,010-1,173) | 175 (160-203) | +| `read_frame`, parked then woken, 160 B | 9.04 (7.74-10.9) | 5.16 (3.76-6.42) | + +Both generators resolve one JS Promise per `rust_future_poll` wakeup; BoltFFI's async +path is 1.4 to 2.1 times cheaper. UniFFI's bytes cost about 7 ns per byte written and 8 ns +per byte read, from its per-element `Vec` lift and lower in Rust, against BoltFFI's +bulk copies at 0.5 and 1.3. That is [Payload bytes](/quest/m2/ffi-bytes.md)'s target. +`@ubjs/core` also arms and clears a `setTimeout` on every async call, a Node.js keep-alive +that runs in the browser too. + +moq-ffi itself through UniFFI, in the same session, in each of ubrn's dispatch modes (see +the strict CSP below): + +| call | `new Function` dispatch | interpreted (strict CSP) | +|---|---|---| +| module init | 53.7 ms | 51.7 ms | +| sync getter `sequence()` | 0.37 | 1.03 | +| `write_frame` 160 B | 2.76 | 3.39 | +| `write_frame` 128 KiB | 922 | 951 | +| `read_frame`, buffered, 160 B | 7.10 | 8.18 | +| `read_frame`, parked then woken, 160 B | 10.5 | 11.2 | + +## Parity + +[`uniffi/cases.ts`](uniffi/cases.ts) and the removed `slice/cases.ts` ran against +all three relays, normally and under a strict CSP. Every case passed in both modes for all +three bindings. moq-wasm's column is the existing suite in [`src/main.ts`](src/main.ts). + +| case | moq-wasm | moq-ffi, UniFFI | slice, UniFFI and BoltFFI | +|---|---|---|---| +| negotiates each relay's version | yes, via `version()` | yes, by offering only that version: no negotiated-version accessor | same as moq-ffi | +| reads a published broadcast, every byte, 128 KiB frames | yes | yes | yes | +| refuses a missing track | yes | yes: rejects with `NotFound` (transport-19) or `Protocol` (lite) | yes: rejects with the slice's `Net { reason: "not found" }` | +| publishes | no: consume only | yes | yes | +| frame timestamps | no: payload only | yes, exact, as `bigint` microseconds | yes | +| u64 sequence above 2^53, end to end | not testable: no publisher can send it | yes, 2^53+1 on all three; 2^62-1 on lite-02 and transport-19 (see bugs) | same as moq-ffi | +| cancellation through `AbortSignal` | no: `free()` rejects pending calls | rejects with `AbortError`; the next call still gets the next frame | UniFFI: `AbortError`; BoltFFI: `BoltFFICancelledError`; neither steals the next frame | +| aborting releases the call's state | n/a | yes: a track's demand drops only once the aborted future is freed | not testable: the slice has no demand | +| freeing a handle mid-call | rejects the call | the call keeps running (each call clones the `Arc`); `cancel()` is the terminal | the call keeps running under both (BoltFFI's `dispose()`) | +| close and error propagation | yes | session `cancel()` rejects a pending remote read with `Transport` | yes, with the browser's `WebTransportError` in the reason | +| forced wasm memory growth mid-read | yes | yes | yes | +| WebSocket fallback | no | no: moq-ffi's wasm client has none | no | + +Things neither generator can do: a `JsValue` cannot cross, so Rust constructs the +WebTransport, as moq-ffi already does on wasm. There are no streams; reads are one Promise +per call. UniFFI's generated `*Like` return types omit `uniffiDestroy`, so a caller casts +to the class to free a handle early; BoltFFI's classes expose `dispose()` directly. Both +fall back to a `FinalizationRegistry`. BoltFFI types an async constructor's result as +nullable (`Promise`, [boltffi#95](https://github.com/boltffi/boltffi/issues/95)). + +## Deployment + +- **Strict CSP.** ubrn compiles a dispatcher per export with `new Function`. Under + `script-src 'self' 'wasm-unsafe-eval'` it falls back to an interpreter; the harness + checks `new Function` really is refused there. Sync calls cost up to 2.8 times as much + interpreted; frame calls barely move. BoltFFI's cases pass under the same CSP. +- **Bundlers.** ubrn's `uniffiInitAsync` and BoltFFI's `init` both take the `.wasm` from + the host. ubrn's `.wasm` and its wasm-bindgen `_bg.js` are a matched pair. Both generated + entrypoints import an npm runtime (`@ubjs/wasm`, `@boltffi/runtime`). +- **Types.** ubrn's generated TypeScript carries `@ts-nocheck`, so + [ubrn#472](https://github.com/jhugman/uniffi-bindgen-react-native/issues/472) does not + surface. BoltFFI emits `.d.ts` files. The harness type-checks its callers against both + under the repo's strict config. +- **Version.** ubrn pins UniFFI 0.31. Adoption waits on + [ubrn#468](https://github.com/jhugman/uniffi-bindgen-react-native/pull/468) (0.32), or + on carrying ubrn's TypeScript generator in [the fork](/quest/m2/uniffi-fork.md). BoltFFI + 0.31.1 is current but breaks across minor releases. +- **JS only.** ubrn's player cannot drive wasm2js output as is: it compiles small wasm + trampolines for callbacks and the panic log, and grows `__indirect_function_table`. Its + module class accepts any instance-shaped object, so plain calls might run through a shim. + Not attempted for either generator; an input to the + [WASM-to-JS evaluation](/quest/m2/rs2ts-wasm2js.md). + +## What blocks BoltFFI + +BoltFFI cannot bind moq-ffi without a rewrite of its public API. Adding the slice's browser +client hit two new walls, and a probe crate confirmed which moq-ffi shapes fail. Together +with [#5736](https://github.com/moq-dev/moq/pull/5736)'s list: + +| blocker | moq-ffi's exposure | what the slice did | to bind moq-ffi | +|---|---|---|---| +| Any `Arc`, returned or in a record, fails the whole crate's lowering ("opaque Rust pointer container cannot be represented in binding IR yet") | 56 exports return `Arc` | classes by value | every object becomes a cheap-clone wrapper around its own `Arc`, M | +| Objects inside records fail too ("unsupported expansion: codec node") | `MoqClientConfig` and `MoqServerConfig` carry origins; two media records carry a timebase: 6 fields | origins live in the session | **public API break** in every language: move those objects to constructor arguments or setters, M | +| Async exports must return `Send` futures, on wasm32 too (`boltffi_core::rust_future_new`); no single-threaded mode | every browser-touching async export; WebTransport handles are `Rc` | wrapped each future in an asserted-`Send` adapter | the same wrapper on every async export, S; or a `Send`-free path in a fork, S | +| Classes must be `Send + Sync` on wasm | 47 objects | `unsafe impl` per class (from #5736) | the same, S | +| The scanner ignores target `cfg`, and binding metadata comes from a host build, so wasm-only code must compile natively | 82 `target_arch` lines | a `browser` feature, with a host stub that errors | convert the wasm gating to features with host stubs, M | +| Markers cannot sit in `cfg_attr`, and `#[error]` collides with thiserror | `MoqError` derives thiserror; all 63 export blocks, 45 records, 16 enums | shim crate (from #5736) | dual annotations through a shim, or BoltFFI only, M to L | +| No Go or C++, partial Dart and C (#5736) | 4 of moq-ffi's 8 targets | n/a | keep UniFFI for those targets and carry both generators, or write the backends, XL | + +Shapes that do bind, from the probe: field defaults (`#[boltffi::default]`, moq-ffi has +97\), `HashMap` as `Map`, `Option` returns, sync and async, and error enums that +carry records. + +So binding moq-ffi through BoltFFI is an L to XL project with a breaking API change. The +cheapest form is browser-only: BoltFFI for TypeScript and UniFFI for the other seven +targets, over one dual-annotated crate. + +## Bugs found + +- **moq-ffi panicked creating any broadcast in the browser.** `moq_mux`'s catalog clock + called `std::time::SystemTime::now`, which wasm32-unknown-unknown does not implement. + The measurements above used a workaround in the pin patch. Since + [#5681](https://github.com/moq-dev/moq/pull/5681), moq-mux reads moq-clock's browser + wall clock instead, and the cases pass without the workaround. +- **On moq-lite-06, group sequence 2^62-1 never arrives.** lite-02 and transport-19 + carry it, and 2^53+1 crosses all three. Nothing errors: the subscriber waits forever. + Both moq-ffi and the slice hit it under both generators. Tracked in + [lite-06 max sequence](/quest/m1/lite06-max-sequence.md). diff --git a/test/wasm/package.json b/test/wasm/package.json index 2351e05967..1fa82913b4 100644 --- a/test/wasm/package.json +++ b/test/wasm/package.json @@ -8,6 +8,8 @@ "@moq/wasm": "workspace:*" }, "devDependencies": { + "@ubjs/core": "0.31.0-6", + "@ubjs/wasm": "0.31.0-6", "playwright": "^1.63.0" } } diff --git a/test/wasm/run.sh b/test/wasm/run.sh index 0270fe1225..b74d06ac13 100755 --- a/test/wasm/run.sh +++ b/test/wasm/run.sh @@ -35,6 +35,7 @@ PORT="${WASM_PORT:-}" # Cargo profile for the relay. Debug compiles faster, which is what a test # fixture wants; the workload is three connections and a few hundred KiB. BASELINE= +UNIFFI= PROFILE="${WASM_PROFILE:-debug}" RELAY="${RELAY_BIN:-}" @@ -45,6 +46,11 @@ while [[ $# -gt 0 ]]; do export MOQ_TEST_KEEP=1 shift ;; + --uniffi) + # moq-ffi over ubrn's wasm2 player, as a third backend. See README.md. + UNIFFI=true + shift + ;; --timeout) if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then echo "error: --timeout requires a value" >&2 @@ -100,6 +106,11 @@ command -v wasm-bindgen >/dev/null 2>&1 || { echo "building @moq/wasm..." (cd "$WORKSPACE" && just js wasm) +if [[ -n $UNIFFI ]]; then + echo "building moq-ffi over ubrn (UniFFI 0.31 pin, scratch checkout)..." + "$WASM_DIR/uniffi/build.sh" +fi + echo "building moq-relay ($PROFILE)..." flag=() [[ "$PROFILE" == "debug" ]] || flag=(--profile "$PROFILE") @@ -118,6 +129,8 @@ bunx playwright install chromium # binding can compile, run, and still publish typings that name the wrong class. echo "type-checking against js/wasm/dist/moq.d.ts..." bunx tsc --noEmit +# The generated TypeScript opts out with @ts-nocheck, so this checks what callers see. +[[ -z $UNIFFI ]] || bunx tsc --noEmit -p uniffi/tsconfig.json echo "bundling the harness page..." rm -rf dist @@ -127,10 +140,15 @@ if [[ -n $BASELINE ]]; then bun build baseline/publisher.ts baseline/consumer.ts --outdir dist --target browser >/dev/null bun build ../../js/net/src/index.ts --outfile dist/net.js --target browser --minify >/dev/null driver=baseline/driver.ts + if [[ -n $UNIFFI ]]; then + bun build uniffi/dist/index.ts --outfile dist/uniffi.js --target browser --minify >/dev/null + export MOQ_WASM_UNIFFI=1 + fi else bun build src/main.ts --outdir dist --target browser >/dev/null cp index.html dist/index.html fi +[[ -z $UNIFFI ]] || bun build uniffi/cases.ts --outfile dist/uniffi-cases.js --target browser >/dev/null # ── relays ────────────────────────────────────────────────────────────────── # One reservation per flavour rather than a span from a fixed base: a span means @@ -181,6 +199,11 @@ status=0 harness_spawn driver - bun "$driver" --relays "$HARNESS_RUN/relays.json" --timeout "$TIMEOUT" harness_wait "$HARNESS_PID" || status=$? +if [[ $status -eq 0 && -n $UNIFFI ]]; then + harness_spawn uniffi - bun uniffi/driver.ts --relays "$HARNESS_RUN/relays.json" + harness_wait "$HARNESS_PID" || status=$? +fi + if [[ $status -ne 0 ]]; then for flavour in "${FLAVOURS[@]}"; do name="${flavour%%:*}" diff --git a/test/wasm/uniffi/build.sh b/test/wasm/uniffi/build.sh new file mode 100755 index 0000000000..4948ba23b7 --- /dev/null +++ b/test/wasm/uniffi/build.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Build moq-ffi as TypeScript over ubrn's wasm2 player, into uniffi/dist. +# +# ubrn 0.31.0-6 reads UniFFI 0.31 metadata and moq-ffi is on 0.32, so this applies +# pin.patch to a scratch checkout of HEAD rather than to this tree. The pin is for +# measurement only; adoption waits on ubrn's 0.32 support. See ../README.md. +set -euo pipefail + +dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +root=$(cd "$dir/../../.." && pwd) +target="${CARGO_TARGET_DIR:-$root/target}" +out="$target/test/wasm-uniffi" +src="$out/src" +ubrn_version=0.31.0-6 + +# Skip the whole build when neither the commit nor the patch changed since the last one. +stamp="$(git -C "$root" rev-parse HEAD) $(sha256sum <"$dir/pin.patch") $ubrn_version" +if [[ -f "$dir/dist/moq_ffi.wasm" && "$(cat "$out/stamp" 2>/dev/null)" == "$stamp" ]]; then + exit 0 +fi + +ubrn="$out/tools/bin/uniffi-bindgen-react-native" +[[ -x "$ubrn" ]] || + cargo install --locked --quiet --root "$out/tools" --git https://github.com/jhugman/uniffi-bindgen-react-native \ + --tag "$ubrn_version" uniffi-bindgen-react-native + +# A detached worktree of the committed rs/ tree, so the pin never touches this checkout. +git -C "$root" worktree remove --force "$src" 2>/dev/null || rm -rf "$src" +git -C "$root" worktree add --quiet --detach "$src" HEAD +trap 'git -C "$root" worktree remove --force "$src"' EXIT +git -C "$src" apply "$dir/pin.patch" + +# Not --locked: the pin re-resolves UniFFI. Its own target dir, so the pinned moq_ffi.wasm +# never stands in for the real one. +cargo build --quiet --manifest-path "$src/Cargo.toml" --package moq-ffi --profile wasm-release \ + --target wasm32-unknown-unknown --target-dir "$out/cargo" + +# ubrn wants a package.json and its config beside it; neither belongs in the repo. +cat >"$out/package.json" <"$out/ubrn.config.yaml" </dev/null) +echo "$stamp" >"$out/stamp" diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts new file mode 100644 index 0000000000..4ac1e21bb0 --- /dev/null +++ b/test/wasm/uniffi/cases.ts @@ -0,0 +1,426 @@ +/** + * The in-page half of the UniFFI comparison: moq-ffi bound by ubrn's wasm2 player. + * + * `driver.ts` loads this page twice, once normally and once under a strict CSP that + * forbids `new Function`, which forces the player's interpreted dispatcher. + * + * @module + */ +import { + MoqBroadcastProducer, + MoqClient, + MoqClientConfig, + MoqClientTls, + type MoqGroupConsumer, + type MoqGroupConsumerLike, + MoqRoute, + type MoqSessionLike, + type MoqTrackConsumer, + MoqTrackInfo, + uniffiInitAsync, +} from "./dist/index.ts"; + +export interface Relay { + name: string; + url: string; + version: string; +} + +export interface CaseResult { + name: string; + ok: boolean; + error?: string; + /** What the case observed, when the behavior is reported rather than asserted. */ + note?: string; +} + +export interface Bench { + name: string; + iterations: number; + us_per_op: number; +} + +export interface Report { + /** Whether the page allows `new Function`, which the player's fast dispatchers need. */ + eval: boolean; + init_ms: number; + cases: CaseResult[]; + bench: Bench[]; +} + +/** Above 2^53, so a lossy `number` conversion anywhere changes it; the largest QUIC varint. */ +const HIGH = [2n ** 53n + 1n, 2n ** 62n - 1n]; + +/** Microsecond ticks, so `timestampUs` passes through unscaled. */ +const TIMED = MoqTrackInfo.create({ timescale: 1_000_000n }); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function payload(size: number): ArrayBuffer { + const bytes = new Uint8Array(size); + for (let i = 0; i < size; i++) bytes[i] = i % 251; + return bytes.buffer; +} + +/** Resolve `promise`, or reject once `ms` passes. */ +async function within(promise: Promise, ms: number, what: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${what} did not settle in ${ms}ms`)), ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** True when `promise` is still pending after the event loop turns over a few times. */ +/** Map how `promise` settles; a timeout still throws rather than reading as a rejection. */ +function settle( + promise: Promise, + ms: number, + what: string, + resolved: (value: T) => R, + rejected: (err: unknown) => R, +): Promise { + return within(promise.then(resolved, rejected), ms, what); +} + +async function pending(promise: Promise): Promise { + let settled = false; + promise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await sleep(20); + return !settled; +} + +async function connect(relay: Relay): Promise { + const response = await fetch(new URL("/certificate.sha256", relay.url)); + if (!response.ok) throw new Error(`certificate HTTP ${response.status}`); + const url = new URL(relay.url); + url.protocol = "https:"; + const client = new MoqClient( + MoqClientConfig.create({ + // moq-ffi exposes no negotiated version, so offering only the expected one is the check. + versions: [relay.version], + tls: MoqClientTls.create({ fingerprints: [(await response.text()).trim()] }), + }), + ); + return client.connect(url.toString()); +} + +/** A standalone group with a consumer, no session. */ +function localGroup(sequence = 0n) { + const broadcast = new MoqBroadcastProducer(); + const track = broadcast.publishTrack("data", TIMED); + const group = track.createGroup(sequence); + return { broadcast, track, group, consumer: group.consume() }; +} + +const local: Record Promise> = { + "u64 above 2^53 round-trips locally": async () => { + for (const sequence of HIGH) { + const { group, consumer } = localGroup(sequence); + if (consumer.sequence() !== sequence) throw new Error(`sequence ${consumer.sequence()} != ${sequence}`); + group.writeFrame({ payload: payload(1), timestampUs: sequence }); + const frame = await consumer.readFrame(); + if (frame?.timestampUs !== sequence) throw new Error(`timestamp ${frame?.timestampUs} != ${sequence}`); + } + return undefined; + }, + + "AbortSignal rejects a pending read without stealing the next frame": async () => { + const { group, consumer } = localGroup(); + const abort = new AbortController(); + const first = consumer.readFrame({ signal: abort.signal }); + if (!(await pending(first))) throw new Error("read settled with nothing written"); + abort.abort(); + const error = await settle( + first, + 1000, + "aborted read", + () => undefined, + (err: unknown) => err, + ); + if (!error) throw new Error("aborted read resolved"); + group.writeFrame({ payload: payload(3), timestampUs: 0n }); + const next = await within(consumer.readFrame(), 1000, "read after abort"); + if (next?.payload.byteLength !== 3) throw new Error("the frame after an abort went missing"); + return `rejects with ${(error as Error).name}: ${(error as Error).message}`; + }, + + "aborting a pending call releases its handle": async () => { + // The pending future holds a clone of the consumer, so the track stays demanded until + // rust_future_free drops it. Destroying the JS handle alone must not be enough. + const broadcast = new MoqBroadcastProducer(); + const track = broadcast.publishTrack("data", TIMED); + const demand = track.demand(); + const consumer = track.consume(undefined); + await within(demand.used(), 1000, "demand.used"); + const abort = new AbortController(); + const read = consumer.recvGroup({ signal: abort.signal }).catch(() => undefined); + // The generated `*Like` return types omit uniffiDestroy; only the class declares it. + (consumer as MoqTrackConsumer).uniffiDestroy(); + await sleep(20); + const heldByFuture = demand.isUsed(); + abort.abort(); + await read; + await within(demand.unused(), 1000, "demand.unused after abort"); + if (!heldByFuture) throw new Error("demand dropped before the pending future was freed"); + return undefined; + }, + + "freeing a handle mid-call leaves the call running": async () => { + // moq-wasm's free() rejects pending calls. Here every call clones the Arc, so the + // call outlives the handle and cancel() is the explicit way to end it. + const { group, consumer } = localGroup(); + const read = consumer.readFrame(); + (consumer as MoqGroupConsumer).uniffiDestroy(); + if (!(await pending(read))) throw new Error("free settled the pending read"); + group.writeFrame({ payload: payload(2), timestampUs: 0n }); + const frame = await within(read, 1000, "read after free"); + return `resolves later with ${frame?.payload.byteLength} bytes`; + }, + + "cancel() rejects a pending call and every later one": async () => { + const { consumer } = localGroup(); + const read = consumer.readFrame(); + if (!(await pending(read))) throw new Error("read settled with nothing written"); + consumer.cancel(); + const first = await settle( + read, + 1000, + "cancelled read", + () => undefined, + (err: unknown) => err as Error, + ); + if (!first) throw new Error("cancelled read resolved"); + const later = await settle( + consumer.readFrame(), + 1000, + "read after cancel", + () => undefined, + (err: unknown) => err as Error, + ); + if (!later) throw new Error("read after cancel resolved"); + return `${first.message}; then ${later.message}`; + }, + + "closing the producer ends a pending read": async () => { + const { group, consumer } = localGroup(); + const read = consumer.readFrame(); + group.finish(); + const frame = await within(read, 1000, "read after finish"); + if (frame !== undefined) throw new Error("finished group yielded a frame"); + return undefined; + }, +}; + +const remote: Record Promise> = { + "negotiates the relay's version": async (relay) => { + const session = await connect(relay); + session.cancel(0); + return undefined; + }, + + "u64 above 2^53 crosses the relay": async (relay) => { + const publisher = await connect(relay); + const subscriber = await connect(relay); + try { + const path = `uniffi/u64/${crypto.randomUUID()}`; + const broadcast = publisher.publish().createBroadcast(path); + const track = broadcast.publishTrack("data", TIMED); + broadcast.announce(MoqRoute.create({})); + const consumer = await within(subscriber.consume().announcedBroadcast(path).available(), 5000, "announce"); + const sub = await consumer.subscribeTrack("data", undefined); + const seen: string[] = []; + for (const sequence of [5n, 2n ** 32n, 2n ** 53n - 1n, ...HIGH]) { + const group = track.createGroup(sequence); + group.writeFrame({ payload: payload(160), timestampUs: sequence }); + group.finish(); + const next = within(sub.recvGroup(), 5000, `recvGroup ${sequence} after [${seen}]`); + // Known: on moq-lite-06 the largest varint never arrives, while lite-02 and IETF carry it. + if (sequence === HIGH[1] && relay.version === "moq-lite-06") { + const outcome = await next.then( + (group) => `arrived as ${group?.sequence()}`, + (err: unknown) => (err as Error).message, + ); + if (outcome === `arrived as ${sequence}`) + throw new Error("known lite-06 gap now passes; update the case"); + return `known gap: 2^62-1 ${outcome}`; + } + const got = (await next) as MoqGroupConsumerLike | undefined; + if (got?.sequence() !== sequence) throw new Error(`sequence ${got?.sequence()} != ${sequence}`); + const frame = await got.readFrame(); + // moq-lite-02 carries no timestamp; every other version must keep it exact. + if (relay.version !== "moq-lite-02" && frame?.timestampUs !== sequence) + throw new Error(`timestamp ${frame?.timestampUs} != ${sequence}`); + seen.push(String(sequence)); + } + return undefined; + } finally { + publisher.cancel(0); + subscriber.cancel(0); + } + }, + + "refuses a track the publisher does not serve": async (relay) => { + const publisher = await connect(relay); + const subscriber = await connect(relay); + try { + const path = `uniffi/refuse/${crypto.randomUUID()}`; + const broadcast = publisher.publish().createBroadcast(path); + broadcast.publishTrack("data", TIMED); + broadcast.announce(MoqRoute.create({})); + const consumer = await within(subscriber.consume().announcedBroadcast(path).available(), 5000, "announce"); + const outcome = await settle( + consumer.subscribeTrack("missing", undefined).then((track) => track.recvGroup()), + 5000, + "refused subscribe", + (group) => (group ? "a group" : "no group"), + (err: unknown) => `rejected: ${(err as Error).message}`, + ); + if (outcome === "a group") throw new Error("a missing track yielded a group"); + return outcome; + } finally { + publisher.cancel(0); + subscriber.cancel(0); + } + }, + + "session cancel rejects a pending remote read": async (relay) => { + const publisher = await connect(relay); + const subscriber = await connect(relay); + try { + const path = `uniffi/close/${crypto.randomUUID()}`; + const broadcast = publisher.publish().createBroadcast(path); + const track = broadcast.publishTrack("data", TIMED); + broadcast.announce(MoqRoute.create({})); + const consumer = await within(subscriber.consume().announcedBroadcast(path).available(), 5000, "announce"); + const sub = await consumer.subscribeTrack("data", undefined); + const group = track.appendGroup(); + group.writeFrame({ payload: payload(4), timestampUs: 0n }); + const got = await within(sub.recvGroup(), 5000, "recvGroup"); + if (!got) throw new Error("no group"); + await got.readFrame(); + const read = got.readFrame(); + if (!(await pending(read))) throw new Error("read settled early"); + subscriber.cancel(1); + const outcome = await settle( + read, + 5000, + "read after session cancel", + (frame) => (frame ? "a frame" : "end of group"), + (err: unknown) => `rejected: ${(err as Error).message}`, + ); + if (outcome === "a frame") throw new Error("read yielded a frame after cancel"); + return outcome; + } finally { + publisher.cancel(0); + } + }, +}; + +/** Per-call costs with no network, so only the binding and moq-net's model are measured. */ +async function bench(): Promise { + const results: Bench[] = []; + // Each batch is a fresh group, under moq-net's 8192-frame and 32 MiB group caps. + let BATCH = 1000; + const measure = async (name: string, batches: number, body: () => Promise) => { + await body(); // warm the dispatchers + let elapsed = 0; + for (let i = 0; i < batches; i++) elapsed += await body(); + results.push({ name, iterations: batches * BATCH, us_per_op: (elapsed * 1000) / (batches * BATCH) }); + }; + + await measure("sync getter: group.sequence()", 10, async () => { + const { consumer } = localGroup(); + const start = performance.now(); + for (let i = 0; i < BATCH; i++) consumer.sequence(); + return performance.now() - start; + }); + + for (const size of [160, 4096, 131072]) { + const bytes = payload(size); + BATCH = Math.min(1000, (16 << 20) / size); + const batches = size > 4096 ? 8 : 10; + await measure(`sync write_frame ${size}B`, batches, async () => { + const { group, broadcast } = localGroup(); + const start = performance.now(); + for (let i = 0; i < BATCH; i++) group.writeFrame({ payload: bytes, timestampUs: 0n }); + const elapsed = performance.now() - start; + broadcast.close(); + return elapsed; + }); + await measure(`async read_frame, buffered ${size}B`, batches, async () => { + const { group, consumer, broadcast } = localGroup(); + for (let i = 0; i < BATCH; i++) group.writeFrame({ payload: bytes, timestampUs: 0n }); + const start = performance.now(); + for (let i = 0; i < BATCH; i++) await consumer.readFrame(); + const elapsed = performance.now() - start; + broadcast.close(); + return elapsed; + }); + } + + BATCH = 1000; + // Each read parks first, so it pays the continuation: a poll, a JS Promise per wakeup, a re-poll. + const bytes = payload(160); + await measure("async read_frame, parked then woken 160B", 5, async () => { + const { group, consumer, broadcast } = localGroup(); + const start = performance.now(); + for (let i = 0; i < BATCH; i++) { + const read = consumer.readFrame(); + group.writeFrame({ payload: bytes, timestampUs: 0n }); + await read; + } + const elapsed = performance.now() - start; + broadcast.close(); + return elapsed; + }); + return results; +} + +async function run(relays: Relay[]): Promise { + const start = performance.now(); + // The page sets the URL; under the strict CSP this also probes `new Function` and falls back. + await uniffiInitAsync(new URL("/uniffi/moq_ffi.wasm", location.href)); + const init_ms = performance.now() - start; + + const cases: CaseResult[] = []; + const record = async (name: string, body: () => Promise) => { + try { + const note = await within(body(), 15000, name); + cases.push({ name, ok: true, note }); + } catch (err) { + cases.push({ name, ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }; + for (const [name, body] of Object.entries(local)) await record(name, body); + for (const relay of relays) + for (const [name, body] of Object.entries(remote)) await record(`${relay.name}: ${name}`, () => body(relay)); + + let evaluates = true; + try { + new Function(""); + } catch { + evaluates = false; + } + return { eval: evaluates, init_ms, cases, bench: await bench() }; +} + +declare global { + interface Window { + uniffiCases: typeof run; + } +} +window.uniffiCases = run; diff --git a/test/wasm/uniffi/check.ts b/test/wasm/uniffi/check.ts new file mode 100644 index 0000000000..5e6d61221e --- /dev/null +++ b/test/wasm/uniffi/check.ts @@ -0,0 +1,5 @@ +// Compile-time only: the generated bindings must satisfy what the baseline consumer assumes. +import type { Uniffi } from "../baseline/uniffi.ts"; +import * as generated from "./dist/index.ts"; + +export const check: Uniffi = generated; diff --git a/test/wasm/uniffi/driver.ts b/test/wasm/uniffi/driver.ts new file mode 100644 index 0000000000..a1ff8c9d61 --- /dev/null +++ b/test/wasm/uniffi/driver.ts @@ -0,0 +1,104 @@ +/** + * Run the UniFFI cases in headless Chromium, once with the player's `new Function` + * dispatchers and once under a strict CSP that forces its interpreter. + * + * Writes `uniffi.json` into the harness run directory. Fails on any failed case. + */ +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { chromium } from "playwright"; +import type { Relay, Report } from "./cases.ts"; + +// `--timeout` bounds each page, init and cases and bench together, in seconds. Each case +// carries its own 15 s bound in the page; this catches a hang outside them. +const { values } = parseArgs({ options: { relays: { type: "string" }, timeout: { type: "string", default: "300" } } }); +const run = process.env.MOQ_TEST_RUN; +const timeout = Number(values.timeout) * 1000; +if (!values.relays || !run || !Number.isFinite(timeout) || timeout <= 0) + throw new Error("needs --relays, MOQ_TEST_RUN, and a positive --timeout"); +const relays = (await Bun.file(values.relays).json()) as Relay[]; +const here = new URL(".", import.meta.url).pathname; +const dist = join(here, "../dist"); + +// No 'unsafe-eval': the player's registration probe fails and it interprets instead. +const STRICT = "script-src 'self' 'wasm-unsafe-eval'"; + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/") + return new Response(``, { + headers: { + "content-type": "text/html", + "cache-control": "no-store", + ...(url.searchParams.has("strict") ? { "content-security-policy": STRICT } : {}), + }, + }); + const file = url.pathname.startsWith("/uniffi/") + ? Bun.file(join(here, "dist", url.pathname.slice(8))) + : Bun.file(join(dist, url.pathname)); + return (await file.exists()) + ? new Response(file, { headers: { "cache-control": "no-store" } }) + : new Response("not found", { status: 404 }); + }, +}); + +const browser = await chromium.launch({ channel: "chromium", headless: true }); +const version = browser.version(); +const reports: Record = {}; +let failed = 0; +try { + for (const mode of ["jit", "strict"] as const) { + const context = await browser.newContext(); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.text().includes("panicked at")) errors.push(`panic: ${message.text()}`); + }); + await page.goto(`http://127.0.0.1:${server.port}/${mode === "strict" ? "?strict" : ""}`); + await page.waitForFunction(() => typeof window.uniffiCases === "function"); + const report = await bounded( + page.evaluate((value) => window.uniffiCases(value), relays), + mode, + ); + if (errors.length) throw new Error(`${mode}: ${errors.join("; ")}`); + reports[mode] = report; + if (report.eval !== (mode === "jit")) throw new Error(`${mode}: new Function allowed = ${report.eval}`); + console.log(`── ${mode}: init ${report.init_ms.toFixed(1)}ms`); + for (const result of report.cases) { + if (!result.ok) failed++; + const detail = result.ok ? (result.note ?? "") : result.error; + console.log(` ${result.ok ? "ok " : "FAIL"} ${result.name}${detail ? ` -- ${detail}` : ""}`); + } + for (const bench of report.bench) console.log(` ${bench.us_per_op.toFixed(2).padStart(9)} us ${bench.name}`); + await context.close(); + } +} finally { + await browser.close(); + server.stop(true); + await Bun.write(join(run, "uniffi.json"), JSON.stringify({ browser: version, reports }, null, 2)); +} +if (failed) { + console.error(`${failed} UniFFI case(s) failed`); + process.exit(1); +} + +async function bounded(task: Promise, mode: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + task, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`${mode}: page did not finish in ${timeout / 1000}s`)), + timeout, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/test/wasm/uniffi/pin.patch b/test/wasm/uniffi/pin.patch new file mode 100644 index 0000000000..a8f72f394a --- /dev/null +++ b/test/wasm/uniffi/pin.patch @@ -0,0 +1,40 @@ +diff --git a/Cargo.toml b/Cargo.toml +index 5ede5997b..181a2fa6f 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -237,7 +237,7 @@ tracing-subscriber = "0.3" + tracing-test = "0.2" + # Runtime used by the published moq-ffi crate. The unpublished bindgen CLI + # carries generator-only patches against this release in its own manifest. +-uniffi = "0.32" ++uniffi = "=0.31" + url = "2" + usage = { package = "usage-rs", version = "6.11.1" } + uuid = { version = "1.26", features = ["v7"] } +diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml +index 40254d1d2..34acfca78 100644 +--- a/rs/moq-ffi/Cargo.toml ++++ b/rs/moq-ffi/Cargo.toml +@@ -74,6 +74,7 @@ getrandom = { workspace = true } + # `UniffiCompatibleFuture` and `FfiConverterArc`, which moq-net's wasm futures + # (LocalBoxFuture) and Rc-backed handles cannot satisfy. + uniffi = { workspace = true, features = ["wasm-unstable-single-threaded"] } ++uniffi-runtime-wasm = "=0.31.0-6" + web-async = { workspace = true } + web-transport-trait = { workspace = true } + web-transport-wasm = { workspace = true } +diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs +index 090bd368e..fd7013830 100644 +--- a/rs/moq-ffi/src/lib.rs ++++ b/rs/moq-ffi/src/lib.rs +@@ -36,6 +36,10 @@ mod transport; + #[cfg(all(feature = "video", not(target_arch = "wasm32")))] + pub mod video; + ++// ubrn's wasm2 player calls its allocator and panic hook, which nothing in Rust names. ++#[cfg(target_arch = "wasm32")] ++extern crate uniffi_runtime_wasm as _; ++ + uniffi::setup_scaffolding!("moq"); + + /// Stop the runtime workers before the host runtime goes away. diff --git a/test/wasm/uniffi/size.ts b/test/wasm/uniffi/size.ts new file mode 100644 index 0000000000..ceb19eafae --- /dev/null +++ b/test/wasm/uniffi/size.ts @@ -0,0 +1,158 @@ +/** + * Attribute a `.wasm` file's bytes to sections, and its code to crates by function name. + * + * bun uniffi/size.ts ... + * + * A generic function counts toward the crate its demangled path starts with, so + * `>::unwrap_or_default` is `core`. That + * undercounts crates whose code is mostly monomorphized elsewhere; read it as a + * coarse share, which is all the report claims. + */ +import { gzipSync } from "bun"; + +function leb(bytes: Uint8Array, at: { pos: number }): number { + let result = 0; + let shift = 0; + for (;;) { + const byte = bytes[at.pos++]; + result += (byte & 0x7f) * 2 ** shift; + if (!(byte & 0x80)) return result; + shift += 7; + } +} + +function name(bytes: Uint8Array, at: { pos: number }): string { + const length = leb(bytes, at); + const text = new TextDecoder().decode(bytes.subarray(at.pos, at.pos + length)); + at.pos += length; + return text; +} + +const SECTIONS = [ + "custom", + "type", + "import", + "function", + "table", + "memory", + "global", + "export", + "start", + "element", + "code", + "data", + "datacount", +]; + +/** The crate a demangled function path belongs to, or a bucket for what has none. */ +function crate(fn: string): string { + const path = fn.replace(/^<+/, ""); + const match = /^([A-Za-z_][A-Za-z0-9_]*)(\[[0-9a-f]+\])?::/.exec(path); + if (match) return match[1]; + if (fn.startsWith("__wbg") || fn.startsWith("__wbindgen")) return "(wasm-bindgen)"; + if (fn.startsWith("uniffi_") || fn.startsWith("ffi_")) return "(uniffi exports)"; + return "(other)"; +} + +function analyze(file: string, bytes: Uint8Array) { + const at = { pos: 8 }; + const sections: Record = {}; + const bodies: number[] = []; + let imported = 0; + const names = new Map(); + // Every section but the custom ones, which a shipped module can drop. + const kept: Uint8Array[] = [bytes.subarray(0, 8)]; + while (at.pos < bytes.length) { + const start = at.pos; + const id = bytes[at.pos++]; + const size = leb(bytes, at); + const end = at.pos + size; + let label = SECTIONS[id] ?? `unknown${id}`; + if (id === 0) { + const inner = { pos: at.pos }; + label = `custom:${name(bytes, inner)}`; + if (label === "custom:name") { + while (inner.pos < end) { + const kind = bytes[inner.pos++]; + const length = leb(bytes, inner); + const stop = inner.pos + length; + if (kind === 1) { + for (let count = leb(bytes, inner); count > 0; count--) { + const index = leb(bytes, inner); + names.set(index, name(bytes, inner)); + } + } + inner.pos = stop; + } + } + } else if (id === 2) { + const inner = { pos: at.pos }; + for (let count = leb(bytes, inner); count > 0; count--) { + name(bytes, inner); + name(bytes, inner); + const kind = bytes[inner.pos++]; + if (kind === 0) { + imported++; + leb(bytes, inner); + } else if (kind === 1) { + inner.pos++; + const flags = leb(bytes, inner); + leb(bytes, inner); + if (flags & 1) leb(bytes, inner); + } else if (kind === 2) { + const flags = leb(bytes, inner); + leb(bytes, inner); + if (flags & 1) leb(bytes, inner); + } else if (kind === 3) { + inner.pos += 2; + } + } + } else if (id === 10) { + const inner = { pos: at.pos }; + for (let count = leb(bytes, inner); count > 0; count--) { + const length = leb(bytes, inner); + bodies.push(length); + inner.pos += length; + } + } + sections[label] = (sections[label] ?? 0) + size; + if (id !== 0) kept.push(bytes.subarray(start, end)); + at.pos = end; + } + + const crates = new Map(); + bodies.forEach((length, index) => { + const owner = crate(names.get(imported + index) ?? ""); + crates.set(owner, (crates.get(owner) ?? 0) + length); + }); + const code = sections.code ?? 0; + return { + file, + bytes: bytes.length, + gzip: gzipSync(bytes).length, + stripped_gzip: gzipSync(Buffer.concat(kept)).length, + sections, + named: names.size > 0, + crates: [...crates] + .sort((a, b) => b[1] - a[1]) + .map(([owner, size]) => ({ crate: owner, bytes: size, share: size / code })), + }; +} + +const reports = []; +for (const file of process.argv.slice(2)) + reports.push(analyze(file, new Uint8Array(await Bun.file(file).arrayBuffer()))); +if (process.env.SIZE_JSON) { + console.log(JSON.stringify(reports, null, 2)); +} else { + for (const report of reports) { + console.log( + `${report.file}: ${report.bytes} B, ${report.gzip} B gzip, ${report.stripped_gzip} B gzip without custom sections`, + ); + for (const [label, size] of Object.entries(report.sections).sort((a, b) => b[1] - a[1])) + console.log(` ${String(size).padStart(9)} ${label}`); + if (!report.named) console.log(" (no name section: crates unattributed)"); + for (const { crate: owner, bytes, share } of report.crates.slice(0, 20)) + console.log(` ${(share * 100).toFixed(1).padStart(5)}% ${String(bytes).padStart(8)} ${owner}`); + } +} diff --git a/test/wasm/uniffi/tsconfig.json b/test/wasm/uniffi/tsconfig.json new file mode 100644 index 0000000000..14cecbbc71 --- /dev/null +++ b/test/wasm/uniffi/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../js/tsconfig.json", + "compilerOptions": { + "types": ["bun"], + "noEmit": true + }, + "include": ["cases.ts", "check.ts", "driver.ts", "size.ts"] +}