From a66d4e53648662d79c091a3a82c1c3d47e6157b4 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:23:55 -0700 Subject: [PATCH 01/10] quest: claim m2/rs2ts/generator From eeea48fdd10968bdc720eaed5f4d7bf4667e3a1d Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:54:12 -0700 Subject: [PATCH 02/10] test(wasm): spike moq-ffi over ubrn's wasm2 player Co-Authored-By: Claude Opus 5.5 --- bun.lock | 6 + test/wasm/package.json | 2 + test/wasm/uniffi/build.sh | 47 +++++ test/wasm/uniffi/cases.ts | 414 +++++++++++++++++++++++++++++++++++++ test/wasm/uniffi/driver.ts | 80 +++++++ test/wasm/uniffi/pin.patch | 65 ++++++ 6 files changed, 614 insertions(+) create mode 100755 test/wasm/uniffi/build.sh create mode 100644 test/wasm/uniffi/cases.ts create mode 100644 test/wasm/uniffi/driver.ts create mode 100644 test/wasm/uniffi/pin.patch diff --git a/bun.lock b/bun.lock index bba31379046..6fb235739a2 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/test/wasm/package.json b/test/wasm/package.json index 2351e05967a..1fa82913b4d 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/uniffi/build.sh b/test/wasm/uniffi/build.sh new file mode 100755 index 00000000000..f0ff1744d1d --- /dev/null +++ b/test/wasm/uniffi/build.sh @@ -0,0 +1,47 @@ +#!/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 + +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) diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts new file mode 100644 index 00000000000..a531119f9c4 --- /dev/null +++ b/test/wasm/uniffi/cases.ts @@ -0,0 +1,414 @@ +/** + * 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, + MoqRoute, + MoqTrackInfo, + type MoqGroupConsumerLike, + type MoqSessionLike, + 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. */ +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() }; +} + +type Case = (relays: Relay[]) => Promise; + +const local: Record = { + "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 within(first, 1000, "aborted read").then( + () => 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); + consumer.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.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 within(read, 1000, "cancelled read").then( + () => undefined, + (err: unknown) => err as Error, + ); + if (!first) throw new Error("cancelled read resolved"); + const later = await within(consumer.readFrame(), 1000, "read after cancel").then( + () => 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 within( + consumer.subscribeTrack("missing", undefined).then((track) => track.recvGroup()), + 5000, + "refused subscribe", + ).then( + (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 within(read, 5000, "read after session cancel").then( + (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(relays)); + 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/driver.ts b/test/wasm/uniffi/driver.ts new file mode 100644 index 00000000000..7ba00d2ec87 --- /dev/null +++ b/test/wasm/uniffi/driver.ts @@ -0,0 +1,80 @@ +/** + * 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"; + +const { values } = parseArgs({ options: { relays: { type: "string" }, timeout: { type: "string", default: "120" } } }); +const run = process.env.MOQ_TEST_RUN; +if (!values.relays || !run) throw new Error("needs --relays and MOQ_TEST_RUN"); +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 page.evaluate((value) => window.uniffiCases(value), relays); + 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); +} diff --git a/test/wasm/uniffi/pin.patch b/test/wasm/uniffi/pin.patch new file mode 100644 index 00000000000..ca93041fc46 --- /dev/null +++ b/test/wasm/uniffi/pin.patch @@ -0,0 +1,65 @@ +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/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs +index dd54a9265..0fac18ea3 100644 +--- a/rs/moq-mux/src/clock.rs ++++ b/rs/moq-mux/src/clock.rs +@@ -43,6 +43,7 @@ fn moq_micros(wall: SystemTime) -> i128 { + } + + /// The host's wall clock, or `None` while it reads before the moq epoch (2020): unset. ++#[cfg(any(test, not(target_arch = "wasm32"), target_os = "wasi"))] + fn host_wall() -> Option { + #[cfg(test)] + let now = HOST_WALL.get().unwrap_or_else(SystemTime::now); +@@ -51,6 +52,12 @@ fn host_wall() -> Option { + (moq_micros(now) >= 0).then_some(now) + } + ++/// Spike only: std has no wall clock in the browser and panics, so leave it unset. ++#[cfg(all(not(test), target_arch = "wasm32", not(target_os = "wasi")))] ++fn host_wall() -> Option { ++ None ++} ++ + #[cfg(test)] + thread_local! { + /// What [`host_wall`] reads on this thread instead of the system clock. From e2e32420b05dec3c552fcd1bebde6dec2df43993 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:56:19 -0700 Subject: [PATCH 03/10] test(wasm): add a uniffi backend to the baseline sweep Co-Authored-By: Claude Opus 5.5 --- test/wasm/baseline/consumer.ts | 90 +++++++++++++++++++++++++++++----- test/wasm/baseline/driver.ts | 26 ++++++++-- test/wasm/baseline/fixture.ts | 2 +- test/wasm/baseline/uniffi.ts | 32 ++++++++++++ test/wasm/run.sh | 23 +++++++++ test/wasm/uniffi/build.sh | 7 +++ test/wasm/uniffi/cases.ts | 7 ++- test/wasm/uniffi/check.ts | 5 ++ test/wasm/uniffi/tsconfig.json | 8 +++ 9 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 test/wasm/baseline/uniffi.ts create mode 100644 test/wasm/uniffi/check.ts create mode 100644 test/wasm/uniffi/tsconfig.json diff --git a/test/wasm/baseline/consumer.ts b/test/wasm/baseline/consumer.ts index 4e1fe794480..0ff9b378e3d 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 e528b60033a..86c39f88306 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,10 @@ 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 +118,10 @@ 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. +const order: Sample["backend"][] = uniffi + ? ["js", "js", "wasm", "wasm", "uniffi", "uniffi", "js", "wasm", "uniffi", "uniffi", "wasm", "js"] + : ["js", "js", "wasm", "wasm", "js", "wasm"]; let serial = 0; try { for (const relay of smoke ? relays.slice(0, 1) : relays) { @@ -131,7 +148,8 @@ 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 +157,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 a27d1f87132..c3763a7d4d3 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 00000000000..4b46b00ab52 --- /dev/null +++ b/test/wasm/baseline/uniffi.ts @@ -0,0 +1,32 @@ +/** + * The slice 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/run.sh b/test/wasm/run.sh index 0270fe12256..b74d06ac13b 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 index f0ff1744d1d..4948ba23b73 100755 --- a/test/wasm/uniffi/build.sh +++ b/test/wasm/uniffi/build.sh @@ -13,6 +13,12 @@ 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 \ @@ -45,3 +51,4 @@ rm -rf "$dir/dist" # dev shell's CLI does as long as the pin leaves it alone. (cd "$out" && CARGO_TARGET_DIR="$out/cargo" "$ubrn" build wasm2 --config ubrn.config.yaml --profile wasm-release \ --no-wasm-build >/dev/null) +echo "$stamp" >"$out/stamp" diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts index a531119f9c4..344d88b30e3 100644 --- a/test/wasm/uniffi/cases.ts +++ b/test/wasm/uniffi/cases.ts @@ -11,10 +11,12 @@ import { MoqClient, MoqClientConfig, MoqClientTls, + type MoqGroupConsumer, MoqRoute, MoqTrackInfo, type MoqGroupConsumerLike, type MoqSessionLike, + type MoqTrackConsumer, uniffiInitAsync, } from "./dist/index.ts"; @@ -154,7 +156,8 @@ const local: Record = { await within(demand.used(), 1000, "demand.used"); const abort = new AbortController(); const read = consumer.recvGroup({ signal: abort.signal }).catch(() => undefined); - consumer.uniffiDestroy(); + // 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(); @@ -169,7 +172,7 @@ const local: Record = { // call outlives the handle and cancel() is the explicit way to end it. const { group, consumer } = localGroup(); const read = consumer.readFrame(); - consumer.uniffiDestroy(); + (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"); diff --git a/test/wasm/uniffi/check.ts b/test/wasm/uniffi/check.ts new file mode 100644 index 00000000000..5e6d61221ef --- /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/tsconfig.json b/test/wasm/uniffi/tsconfig.json new file mode 100644 index 00000000000..444e6e04209 --- /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"] +} From 23ed91b6ee53ca47d3a3cc50d69051d11b69c72d Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:16:29 -0700 Subject: [PATCH 04/10] test(wasm): compare the slice's per-call cost under UniFFI and BoltFFI Co-Authored-By: Claude Opus 5.5 --- test/wasm/run.sh | 22 ++++++ test/wasm/slice/adapter.ts | 15 ++++ test/wasm/slice/bench.ts | 105 +++++++++++++++++++++++++ test/wasm/slice/boltffi.ts | 24 ++++++ test/wasm/slice/build.sh | 64 +++++++++++++++ test/wasm/slice/driver.ts | 79 +++++++++++++++++++ test/wasm/slice/tsconfig.json | 8 ++ test/wasm/slice/uniffi.ts | 25 ++++++ test/wasm/uniffi/size.ts | 143 ++++++++++++++++++++++++++++++++++ 9 files changed, 485 insertions(+) create mode 100644 test/wasm/slice/adapter.ts create mode 100644 test/wasm/slice/bench.ts create mode 100644 test/wasm/slice/boltffi.ts create mode 100755 test/wasm/slice/build.sh create mode 100644 test/wasm/slice/driver.ts create mode 100644 test/wasm/slice/tsconfig.json create mode 100644 test/wasm/slice/uniffi.ts create mode 100644 test/wasm/uniffi/size.ts diff --git a/test/wasm/run.sh b/test/wasm/run.sh index b74d06ac13b..e0263b061a9 100755 --- a/test/wasm/run.sh +++ b/test/wasm/run.sh @@ -36,6 +36,7 @@ PORT="${WASM_PORT:-}" # fixture wants; the workload is three connections and a few hundred KiB. BASELINE= UNIFFI= +SLICE= PROFILE="${WASM_PROFILE:-debug}" RELAY="${RELAY_BIN:-}" @@ -51,6 +52,11 @@ while [[ $# -gt 0 ]]; do UNIFFI=true shift ;; + --slice) + # bench/ffi-gen's slice bound by UniFFI and BoltFFI, for per-call costs. + SLICE=true + shift + ;; --timeout) if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then echo "error: --timeout requires a value" >&2 @@ -111,6 +117,11 @@ if [[ -n $UNIFFI ]]; then "$WASM_DIR/uniffi/build.sh" fi +if [[ -n $SLICE ]]; then + echo "building the moq-ffi slice with BoltFFI and UniFFI..." + "$WASM_DIR/slice/build.sh" +fi + echo "building moq-relay ($PROFILE)..." flag=() [[ "$PROFILE" == "debug" ]] || flag=(--profile "$PROFILE") @@ -131,6 +142,7 @@ 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 +[[ -z $SLICE ]] || bunx tsc --noEmit -p slice/tsconfig.json echo "bundling the harness page..." rm -rf dist @@ -149,6 +161,12 @@ else cp index.html dist/index.html fi [[ -z $UNIFFI ]] || bun build uniffi/cases.ts --outfile dist/uniffi-cases.js --target browser >/dev/null +if [[ -n $SLICE ]]; then + bun build slice/bench.ts --outfile dist/slice-bench.js --target browser >/dev/null + for generator in uniffi boltffi; do + bun build "slice/$generator.ts" --outfile "dist/slice-$generator.js" --target browser --minify >/dev/null + done +fi # ── relays ────────────────────────────────────────────────────────────────── # One reservation per flavour rather than a span from a fixed base: a span means @@ -203,6 +221,10 @@ 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 -eq 0 && -n $SLICE ]]; then + harness_spawn slice - bun slice/driver.ts + harness_wait "$HARNESS_PID" || status=$? +fi if [[ $status -ne 0 ]]; then for flavour in "${FLAVOURS[@]}"; do diff --git a/test/wasm/slice/adapter.ts b/test/wasm/slice/adapter.ts new file mode 100644 index 00000000000..a0340b0d21e --- /dev/null +++ b/test/wasm/slice/adapter.ts @@ -0,0 +1,15 @@ +/** What the slice bench needs from either generator, so one page drives both. */ +export interface Slice { + noop(): void; + noopAsync(): Promise; + yieldOnce(): Promise; + /** A fresh standalone group, a consumer of it, and a way to free both. */ + group(): Group; +} + +export interface Group { + sequence(): bigint; + write(bytes: Uint8Array): void; + read(): Promise; + close(): void; +} diff --git a/test/wasm/slice/bench.ts b/test/wasm/slice/bench.ts new file mode 100644 index 00000000000..ca2c519fb6b --- /dev/null +++ b/test/wasm/slice/bench.ts @@ -0,0 +1,105 @@ +/** + * Per-call costs of the moq-ffi slice in the browser, identical for each generator. + * + * `driver.ts` opens a fresh page per round and passes which generator's adapter to load. + * + * @module + */ +import type { Slice } from "./adapter.ts"; + +export interface Bench { + name: string; + iterations: number; + us_per_op: number; +} + +export interface Report { + generator: string; + init_ms: number; + bench: Bench[]; +} + +// Each batch is a fresh group, under moq-net's 8192-frame and 32 MiB group caps. +const BATCH = 1000; + +async function run(generator: string): Promise { + const start = performance.now(); + // Runtime URLs keep each generator's bundle out of the other's page. + const { open } = (await import(`/slice-${generator}.js`)) as { open(wasm: URL): Promise }; + const slice = await open(new URL(`/slice/${generator}.wasm`, location.href)); + const init_ms = performance.now() - start; + + const bench: Bench[] = []; + const measure = async (name: string, batches: number, per: number, body: () => Promise) => { + await body(); // warm the dispatchers + let elapsed = 0; + for (let i = 0; i < batches; i++) elapsed += await body(); + bench.push({ name, iterations: batches * per, us_per_op: (elapsed * 1000) / (batches * per) }); + }; + const timed = async (body: () => unknown | Promise) => { + const start = performance.now(); + for (let i = 0; i < BATCH; i++) await body(); + return performance.now() - start; + }; + + await measure("sync noop()", 10, BATCH, async () => { + const start = performance.now(); + for (let i = 0; i < BATCH; i++) slice.noop(); + return performance.now() - start; + }); + await measure("sync getter: sequence()", 10, BATCH, async () => { + const group = slice.group(); + const start = performance.now(); + for (let i = 0; i < BATCH; i++) group.sequence(); + const elapsed = performance.now() - start; + group.close(); + return elapsed; + }); + await measure("async noop_async()", 5, BATCH, () => timed(() => slice.noopAsync())); + await measure("async yield_once(): one wakeup", 5, BATCH, () => timed(() => slice.yieldOnce())); + + for (const size of [160, 4096, 131072]) { + const bytes = new Uint8Array(size).map((_, i) => i % 251); + const per = Math.min(BATCH, (16 << 20) / size); + const batches = size > 4096 ? 8 : 10; + await measure(`sync write_frame ${size}B`, batches, per, async () => { + const group = slice.group(); + const start = performance.now(); + for (let i = 0; i < per; i++) group.write(bytes); + const elapsed = performance.now() - start; + group.close(); + return elapsed; + }); + await measure(`async read_frame, buffered ${size}B`, batches, per, async () => { + const group = slice.group(); + for (let i = 0; i < per; i++) group.write(bytes); + const start = performance.now(); + for (let i = 0; i < per; i++) if ((await group.read()) !== size) throw new Error("wrong frame size"); + const elapsed = performance.now() - start; + group.close(); + return elapsed; + }); + } + + const bytes = new Uint8Array(160); + await measure("async read_frame, parked then woken 160B", 5, BATCH, async () => { + const group = slice.group(); + const start = performance.now(); + for (let i = 0; i < BATCH; i++) { + const read = group.read(); + group.write(bytes); + await read; + } + const elapsed = performance.now() - start; + group.close(); + return elapsed; + }); + return { generator, init_ms, bench }; +} + +declare global { + interface Window { + sliceBench: typeof run; + } +} +window.sliceBench = run; diff --git a/test/wasm/slice/boltffi.ts b/test/wasm/slice/boltffi.ts new file mode 100644 index 00000000000..3cdb3f007c8 --- /dev/null +++ b/test/wasm/slice/boltffi.ts @@ -0,0 +1,24 @@ +// The slice bound by BoltFFI, behind the bench's adapter. +import type { Slice } from "./adapter.ts"; +import init, { MoqBroadcastProducer, noop, noopAsync, yieldOnce } from "./dist/boltffi/moq_ffi_gen.js"; + +export async function open(wasm: URL): Promise { + await init(await fetch(wasm)); + return { + noop, + noopAsync: () => noopAsync(), + yieldOnce: () => yieldOnce(), + group() { + const broadcast = MoqBroadcastProducer.new(); + const track = broadcast.publishTrack("data"); + const group = track.appendGroup(); + const consumer = group.consume(); + return { + sequence: () => consumer.sequence(), + write: (bytes) => group.writeFrame({ payload: bytes, timestampUs: 0n }), + read: async () => (await consumer.readFrame())?.payload.byteLength, + close: () => track.finish(), + }; + }, + }; +} diff --git a/test/wasm/slice/build.sh b/test/wasm/slice/build.sh new file mode 100755 index 00000000000..f85a9aea1c2 --- /dev/null +++ b/test/wasm/slice/build.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Bind bench/ffi-gen's moq-ffi slice for the browser twice: BoltFFI's web package, and +# UniFFI over ubrn's wasm2 player. Outputs land in slice/dist/{boltffi,uniffi}. +# +# Works on a scratch copy of the slice, so neither BoltFFI's output nor the UniFFI 0.31 +# pin (ubrn's version; see ../uniffi/build.sh) touches the tree. 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-slice" +slice="$root/bench/ffi-gen" +boltffi_version=0.31.1 +ubrn_version=0.31.0-6 + +[[ -d "$slice" ]] || { + echo "error: $slice is missing; the slice lands with the BoltFFI spike (#5736)" >&2 + exit 1 +} + +boltffi="$out/tools/bin/boltffi" +[[ -x "$boltffi" ]] || cargo install --locked --quiet --root "$out/tools" "boltffi_cli@$boltffi_version" +# BoltFFI transpiles its TypeScript with `tsc`, resolved from an ancestor of its output. +[[ -d "$out/node_modules/@boltffi/runtime" ]] || + npm install --silent --no-save --prefix "$out" typescript@5 @types/node "@boltffi/runtime@$boltffi_version" +ubrn="$target/test/wasm-uniffi/tools/bin/uniffi-bindgen-react-native" +[[ -x "$ubrn" ]] || + cargo install --locked --quiet --root "$target/test/wasm-uniffi/tools" \ + --git https://github.com/jhugman/uniffi-bindgen-react-native --tag "$ubrn_version" uniffi-bindgen-react-native + +rm -rf "$out/src" "$dir/dist" +mkdir -p "$out/src" "$dir/dist" +cp -r "$slice/." "$out/src" + +# BoltFFI: its CLI builds only `debug` or `release`, so release takes wasm-release's opt-level. +sed -i "s|../../target/ffi-gen/boltffi|$out/boltffi|" "$out/src/boltffi.toml" +(cd "$out/src" && PATH="$out/node_modules/.bin:$PATH" CARGO_TARGET_DIR="$out/cargo/boltffi" \ + CARGO_PROFILE_RELEASE_OPT_LEVEL=z "$boltffi" pack wasm --release --deny-skipped >/dev/null) +cp -r "$out/boltffi/npm/." "$dir/dist/boltffi" +# Where bun resolves the generated module's runtime import from when bundling. +mkdir -p "$dir/dist/node_modules/@boltffi" +cp -r "$out/node_modules/@boltffi/runtime" "$dir/dist/node_modules/@boltffi/" + +# UniFFI: the same pin moq-ffi takes, applied to the slice's own manifest. +sed -i 's|uniffi = { version = "0.32"|uniffi = { version = "=0.31"|' "$out/src/Cargo.toml" +sed -i "s|^web-async = .*|&\nuniffi-runtime-wasm = \"=$ubrn_version\"|" "$out/src/Cargo.toml" +printf '\n#[cfg(all(feature = "uniffi", target_arch = "wasm32"))]\nextern crate uniffi_runtime_wasm as _;\n' >>"$out/src/src/lib.rs" +cargo build --quiet --manifest-path "$out/src/Cargo.toml" --profile wasm-release --target wasm32-unknown-unknown \ + --no-default-features --features uniffi --target-dir "$out/cargo/uniffi" +cat >"$out/package.json" <"$out/ubrn.config.yaml" </dev/null) diff --git a/test/wasm/slice/driver.ts b/test/wasm/slice/driver.ts new file mode 100644 index 00000000000..00101372fd2 --- /dev/null +++ b/test/wasm/slice/driver.ts @@ -0,0 +1,79 @@ +/** + * Run the slice bench in headless Chromium, alternating generators across fresh pages so + * drift on a shared host hits both. Writes `slice.json` into the harness run directory. + */ +import { join } from "node:path"; +import { chromium } from "playwright"; +import type { Report } from "./bench.ts"; + +const run = process.env.MOQ_TEST_RUN; +if (!run) throw new Error("needs MOQ_TEST_RUN"); +const rounds = Number(process.env.MOQ_SLICE_ROUNDS ?? 6); +const here = new URL(".", import.meta.url).pathname; +const dist = join(here, "../dist"); +const modules: Record = { + "/slice/uniffi.wasm": join(here, "dist/uniffi/moq_ffi_gen.wasm"), + "/slice/boltffi.wasm": join(here, "dist/boltffi/moq_ffi_gen_bg.wasm"), +}; + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const path = new URL(req.url).pathname; + if (path === "/") + return new Response(``, { + headers: { "content-type": "text/html", "cache-control": "no-store" }, + }); + const file = Bun.file(modules[path] ?? join(dist, path)); + 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: Report[] = []; +try { + for (let round = 0; round < rounds; round++) { + // ABBA order: each generator goes first, and twice in a row, equally often. + const order = round % 2 ? ["boltffi", "uniffi"] : ["uniffi", "boltffi"]; + for (const generator of order) { + const context = await browser.newContext(); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.port}/`); + await page.waitForFunction(() => typeof window.sliceBench === "function"); + const report = await page.evaluate((value) => window.sliceBench(value), generator); + if (errors.length) throw new Error(`${generator}: ${errors.join("; ")}`); + reports.push(report); + console.log(`round ${round} ${generator}: init ${report.init_ms.toFixed(1)}ms`); + await context.close(); + } + } +} finally { + await browser.close(); + server.stop(true); + await Bun.write(join(run, "slice.json"), JSON.stringify({ browser: version, reports }, null, 2)); +} + +// Median per case and generator, with the range, as the report tables show them. +const cases = new Map>(); +for (const report of reports) + for (const bench of report.bench) { + const byGenerator = cases.get(bench.name) ?? new Map(); + byGenerator.set(report.generator, [...(byGenerator.get(report.generator) ?? []), bench.us_per_op]); + cases.set(bench.name, byGenerator); + } +const median = (values: number[]) => { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +}; +const cell = (values: number[] = []) => + values.length ? `${median(values).toFixed(2)} (${Math.min(...values).toFixed(2)}-${Math.max(...values).toFixed(2)})` : "-"; +console.log("| case | UniFFI us | BoltFFI us |\n|---|---|---|"); +for (const [name, byGenerator] of cases) + console.log(`| ${name} | ${cell(byGenerator.get("uniffi"))} | ${cell(byGenerator.get("boltffi"))} |`); diff --git a/test/wasm/slice/tsconfig.json b/test/wasm/slice/tsconfig.json new file mode 100644 index 00000000000..26817e4097c --- /dev/null +++ b/test/wasm/slice/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../js/tsconfig.json", + "compilerOptions": { + "types": ["bun"], + "noEmit": true + }, + "include": ["adapter.ts", "bench.ts", "boltffi.ts", "driver.ts", "uniffi.ts"] +} diff --git a/test/wasm/slice/uniffi.ts b/test/wasm/slice/uniffi.ts new file mode 100644 index 00000000000..691e4082084 --- /dev/null +++ b/test/wasm/slice/uniffi.ts @@ -0,0 +1,25 @@ +// The slice bound by UniFFI over ubrn's wasm2 player, behind the bench's adapter. +import type { Slice } from "./adapter.ts"; +import { MoqBroadcastProducer, noop, noopAsync, uniffiInitAsync, yieldOnce } from "./dist/uniffi/index.ts"; + +export async function open(wasm: URL): Promise { + await uniffiInitAsync(wasm); + return { + noop, + noopAsync: () => noopAsync(), + yieldOnce: () => yieldOnce(), + group() { + const broadcast = new MoqBroadcastProducer(); + const track = broadcast.publishTrack("data"); + const group = track.appendGroup(); + const consumer = group.consume(); + return { + sequence: () => consumer.sequence(), + // The bench's payloads span their whole buffer, so passing it adds no JS copy. + write: (bytes) => group.writeFrame({ payload: bytes.buffer as ArrayBuffer, timestampUs: 0n }), + read: async () => (await consumer.readFrame())?.payload.byteLength, + close: () => track.finish(), + }; + }, + }; +} diff --git a/test/wasm/uniffi/size.ts b/test/wasm/uniffi/size.ts new file mode 100644 index 00000000000..47453954f87 --- /dev/null +++ b/test/wasm/uniffi/size.ts @@ -0,0 +1,143 @@ +/** + * 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}`); + } +} From 3b4ca26fe51da92abfb86d1950a772b1818f623f Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:21:33 -0700 Subject: [PATCH 05/10] test(wasm): report the browser binding generator comparison Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly.yml | 2 +- test/tsconfig.json | 2 +- test/wasm/README.md | 14 +++ test/wasm/baseline/driver.ts | 8 +- test/wasm/generator.md | 213 +++++++++++++++++++++++++++++++++ test/wasm/slice/driver.ts | 4 +- test/wasm/uniffi/cases.ts | 25 ++-- test/wasm/uniffi/size.ts | 21 +++- test/wasm/uniffi/tsconfig.json | 2 +- 9 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 test/wasm/generator.md diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a2d7cf6bc21..14659ff29a6 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/test/tsconfig.json b/test/tsconfig.json index 10187d489f8..0c64e606fec 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", "wasm/slice"] } diff --git a/test/wasm/README.md b/test/wasm/README.md index 0c7e79d94b1..de377ae1606 100644 --- a/test/wasm/README.md +++ b/test/wasm/README.md @@ -172,3 +172,17 @@ 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 +just test wasm --slice +``` + +`--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. `--slice` times the +`bench/ffi-gen` slice under UniFFI and BoltFFI. Both build from scratch copies, since +ubrn needs a UniFFI pin that does not land. [generator.md](generator.md) is the +resulting report. diff --git a/test/wasm/baseline/driver.ts b/test/wasm/baseline/driver.ts index 86c39f88306..ad44b95723d 100644 --- a/test/wasm/baseline/driver.ts +++ b/test/wasm/baseline/driver.ts @@ -97,9 +97,7 @@ const report = { 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"))] - : [], + 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: [ @@ -148,7 +146,9 @@ try { })), ]; for (const shape of smoke ? [shapes[0], shapes[13]] : shapes) { - const backends: Sample["backend"][] = smoke ? ["js", "wasm", ...(uniffi ? (["uniffi"] as const) : [])] : order; + const backends: Sample["backend"][] = smoke + ? ["js", "wasm", ...(uniffi ? (["uniffi"] as const) : [])] + : order; for (const [round, backend] of backends.entries()) { const config: Sample = { ...shape, diff --git a/test/wasm/generator.md b/test/wasm/generator.md new file mode 100644 index 00000000000..c9c014722aa --- /dev/null +++ b/test/wasm/generator.md @@ -0,0 +1,213 @@ +# Browser binding generator report + +Which Rust binding `@moq/net` wraps in the browser: moq-ffi through UniFFI (ubrn's +`wasm2` player), the moq-ffi slice through BoltFFI, or the hand-written wasm-bindgen +`rs/moq-wasm`. This compares them on the [baseline harness](README.md#backend-baseline). +It adopts nothing. + +Decision: open, see the PR that added this file. + +## Reproduce + +```bash +just test wasm-baseline --uniffi # moq-ffi over ubrn: parity cases, then the baseline sweep with a third backend +just test wasm --slice # the slice's per-call costs under UniFFI and BoltFFI (needs bench/ffi-gen, #5736) +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. The patch also carries a +browser fix moq-ffi needs (see [Bugs found](#bugs-found)). moq-ffi built on 0.31 with +no other source change. [`slice/build.sh`](slice/build.sh) does the same to the slice. + +## 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 +13 to 25 during the sweep), so treat absolute numbers as noisy. Every comparison +interleaves its candidates: the sweep runs js, js, wasm, wasm, uniffi, uniffi, js, wasm, +uniffi, uniffi, wasm, js per shape, and the slice 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 | +|---|---|---|---|---|---| +| `@moq/net` (JS) | - | - | 386,552 | 109,486 | 109,486 | +| moq-wasm, wasm-bindgen | 2,194,698 | 730,348 | 39,052 | 7,782 | 738,130 | +| moq-ffi, UniFFI | 6,408,963 | 1,903,175 | 239,899 | 34,751 | 1,937,926 | +| moq-ffi, UniFFI, no custom sections | 4,533,099 | 1,667,925 | 239,899 | 34,751 | 1,702,676 | +| slice, UniFFI | 1,278,308 | 257,617 | 65,628 | 15,853 | 273,470 | +| slice, BoltFFI | 1,175,797 | 231,257 | 53,761 | 11,987 | 243,244 | + +UniFFI's JS is the generated bindings, `@ubjs/core` and `@ubjs/wasm`, and wasm-bindgen's +glue, minified. ubrn stages the module with its `name` section; wasm-bindgen drops it for +moq-wasm, which is worth 235 KB gzip. + +moq-ffi binds the whole surface, so the `.wasm` is 2.3 times moq-wasm even stripped. 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. + +| | JS | moq-wasm | UniFFI | +|---|---|---|---| +| module import and init | 29 ms (26-33) | 21 ms (18-24) | 61 ms (58-69) | +| first frame | 74 ms (60-79) | 79 ms (68-87) | 118 ms (104-130) | +| wasm memory before reads | - | 1.31 MiB | 1.81 MiB | +| JS heap after reads | 3.33 MiB | 2.06 MiB | 3.62 MiB | + +Startup rows are the 160 B 1x1 shape on all three relays. + +CPU per frame, ms, publishers x subscribers: + +| protocol, shape | JS | moq-wasm | UniFFI | +|---|---|---|---| +| lite-06, 160 B, 1x1 | 1.56 (1.47-1.70) | 2.33 (2.17-2.44) | 3.13 (3.06-3.37) | +| lite-06, 160 B, 4x4 | 0.21 (0.21-0.22) | 0.26 (0.25-0.26) | 0.31 (0.29-0.31) | +| lite-06, 4 KiB, 1x4 | 0.57 (0.54-0.61) | 0.73 (0.72-0.76) | 0.97 (0.95-0.98) | +| lite-06, 128 KiB, 1x1 | 2.09 (2.03-2.14) | 3.32 (3.15-3.54) | 4.67 (4.63-4.74) | +| lite-06, 128 KiB, 1x4 | 0.97 (0.93-0.97) | 1.28 (1.23-1.31) | 2.03 (1.96-2.07) | +| lite-06, 128 KiB, 4x4 | 1.44 (0.87-1.65) | 1.69 (1.18-2.17) | 2.23 (1.84-2.77) | +| transport-19, 128 KiB, 1x4 | 1.24 (1.07-1.67) | 1.48 (1.41-1.68) | 2.20 (2.06-2.26) | +| lite-02, 128 KiB, 1x4 | 1.03 (0.91-1.16) | 1.29 (1.27-1.35) | 2.11 (2.06-2.22) | + +The raw samples for every shape, including the u32 and near-2^53 sequences, are in the +run's `backend.json`. UniFFI costs 1.2 to 1.6 times moq-wasm's CPU per frame here, more +as frames grow, since every payload crosses as a record copied byte by byte. + +## Per-call cost + +Local calls, no network, so only the binding and moq-net's model count. Microseconds per +call. + +The slice, six ABBA rounds: + +| call | UniFFI | BoltFFI | +|---|---|---| +| sync `noop()` | 0.65 (0.33-0.74) | 0.03 (0.02-0.04) | +| sync getter `sequence()` | 0.33 (0.27-0.38) | 0.04 (0.01-0.06) | +| async `noop_async()` | 2.33 (2.28-2.62) | 1.50 (1.00-1.74) | +| async `yield_once()`: one wakeup | 3.11 (3.00-3.62) | 1.79 (1.64-2.04) | +| `write_frame` 160 B | 1.64 (1.58-1.73) | 0.76 (0.72-0.85) | +| `write_frame` 4 KiB | 13.6 (13.3-13.8) | 2.26 (2.04-2.35) | +| `write_frame` 128 KiB | 401 (395-420) | 58.5 (51.8-63.2) | +| `read_frame`, buffered, 160 B | 5.47 (5.29-5.69) | 2.57 (2.34-3.28) | +| `read_frame`, buffered, 4 KiB | 38.5 (37.5-39.0) | 8.08 (7.07-8.62) | +| `read_frame`, buffered, 128 KiB | 1,052 (1,039-1,079) | 141 (124-146) | +| `read_frame`, parked then woken, 160 B | 7.19 (6.70-7.82) | 3.75 (3.40-4.00) | + +UniFFI resolves one JS Promise per `rust_future_poll` wakeup, as BoltFFI does; the +async gap is about 2 times. Its bytes cost about 3 ns per byte written and 8 ns per byte +read, from UniFFI's per-element `Vec` lift and lower in Rust plus a JS-side copy, +against BoltFFI's bulk copies. 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, one run per mode (the strict CSP below): + +| call | `new Function` dispatch | interpreted (strict CSP) | +|---|---|---| +| module init | 52.7 ms | 46.4 ms | +| sync getter `sequence()` | 0.34 | 0.89 | +| `write_frame` 160 B | 2.39 | 3.58 | +| `write_frame` 128 KiB | 883 | 933 | +| `read_frame`, buffered, 160 B | 6.41 | 8.54 | +| `read_frame`, parked then woken, 160 B | 9.70 | 9.00 | + +The slice's `sequence()` matches moq-ffi's. Compare the frame rows across these two tables +only roughly: they ran at different times on the shared host, and moq-ffi's frames carry +more bookkeeping than the slice's. + +## Parity + +[`uniffi/cases.ts`](uniffi/cases.ts) runs against all three relays, in both dispatch modes. +moq-wasm's column is the existing suite in [`src/main.ts`](src/main.ts). + +| case | moq-wasm | moq-ffi, UniFFI | +|---|---|---| +| negotiates each relay's version | yes, via `version()` | yes, by offering only that version: moq-ffi exposes no negotiated version | +| reads a published broadcast, every byte, 128 KiB frames | yes | yes | +| refuses a missing track | yes | yes: rejects with `NotFound` (transport-19) or `Protocol` (lite) | +| publishes | no: consume only | yes | +| frame timestamps | no: payload only | yes, exact, as `bigint` microseconds | +| 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) | +| cancellation | `free()` rejects pending calls | every async method takes `{ signal }`; abort rejects with `AbortError` and the next call still gets the next frame | +| aborting releases the call's state | n/a | yes: a track's demand drops only once the aborted future is freed | +| freeing a handle mid-call | rejects the call | the call keeps running: each call clones the `Arc`; `cancel()` is the terminal | +| close and error propagation | yes | session `cancel()` rejects a pending remote read with `Transport` | +| forced wasm memory growth mid-read | yes | yes | +| WebSocket fallback | no | no: moq-ffi's wasm client has none either | + +Things the binding cannot 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. The generated `*Like` return types omit `uniffiDestroy`, so a +caller casts to the class to free a handle early, and a `FinalizationRegistry` frees the +rest. + +## 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. Every case passes in both modes. Sync + calls cost up to 2.6 times as much interpreted; frame calls barely move. +- **Bundlers.** `uniffiInitAsync` takes the `.wasm` URL from the host. The `.wasm` and + its wasm-bindgen `_bg.js` are a matched pair. The generated `index.ts` imports + `@ubjs/wasm`, which resolves its browser build through package `exports`. +- **Types.** The generated TypeScript carries `@ts-nocheck`, so + [ubrn#472](https://github.com/jhugman/uniffi-bindgen-react-native/issues/472) does not + surface. The harness type-checks its callers against it 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). +- **JS only.** The 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; an input to the [WASM-to-JS evaluation](/quest/m2/rs2ts-wasm2js.md). + +## BoltFFI + +The slice binds and runs in Chromium under BoltFFI 0.31.1, and is the fastest on every +call. That passes the feasibility filter, but it is not selection evidence: + +- BoltFFI cannot bind moq-ffi as written. [#5736](https://github.com/moq-dev/moq/pull/5736) + lists eleven gaps, including `Arc` returns, ignored target `cfg`, and `Send + Sync` + on wasm. +- The slice has no client on wasm, because BoltFFI's scanner ignores target `cfg` and + web-transport-wasm is gated on it. So no network parity, size, startup, or memory + numbers exist for it. +- \#5736 recommends forking UniFFI for the native targets. Picking BoltFFI for the browser + alone would carry two generators. + +If BoltFFI is favored anyway, the quest's next step stands: port the harness workload to it +and rerun parity, size, startup, and memory before picking it. + +## Bugs found + +- **moq-ffi panics creating any broadcast in the browser.** `moq_mux`'s catalog clock + calls `std::time::SystemTime::now`, which wasm32-unknown-unknown does not implement. + The pin patch leaves the wall clock unset in the browser, as moq-net already does for + its own; the real fix belongs to the bindings parity work. +- **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. diff --git a/test/wasm/slice/driver.ts b/test/wasm/slice/driver.ts index 00101372fd2..f293c76d522 100644 --- a/test/wasm/slice/driver.ts +++ b/test/wasm/slice/driver.ts @@ -73,7 +73,9 @@ const median = (values: number[]) => { return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }; const cell = (values: number[] = []) => - values.length ? `${median(values).toFixed(2)} (${Math.min(...values).toFixed(2)}-${Math.max(...values).toFixed(2)})` : "-"; + values.length + ? `${median(values).toFixed(2)} (${Math.min(...values).toFixed(2)}-${Math.max(...values).toFixed(2)})` + : "-"; console.log("| case | UniFFI us | BoltFFI us |\n|---|---|---|"); for (const [name, byGenerator] of cases) console.log(`| ${name} | ${cell(byGenerator.get("uniffi"))} | ${cell(byGenerator.get("boltffi"))} |`); diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts index 344d88b30e3..7a86283f4de 100644 --- a/test/wasm/uniffi/cases.ts +++ b/test/wasm/uniffi/cases.ts @@ -12,11 +12,11 @@ import { MoqClientConfig, MoqClientTls, type MoqGroupConsumer, - MoqRoute, - MoqTrackInfo, type MoqGroupConsumerLike, + MoqRoute, type MoqSessionLike, type MoqTrackConsumer, + MoqTrackInfo, uniffiInitAsync, } from "./dist/index.ts"; @@ -222,11 +222,7 @@ const remote: Record Promise> = { 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 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]) { @@ -240,7 +236,8 @@ const remote: Record Promise> = { (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"); + 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; @@ -266,11 +263,7 @@ const remote: Record Promise> = { 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 consumer = await within(subscriber.consume().announcedBroadcast(path).available(), 5000, "announce"); const outcome = await within( consumer.subscribeTrack("missing", undefined).then((track) => track.recvGroup()), 5000, @@ -295,11 +288,7 @@ const remote: Record Promise> = { 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 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 }); diff --git a/test/wasm/uniffi/size.ts b/test/wasm/uniffi/size.ts index 47453954f87..ceb19eafaea 100644 --- a/test/wasm/uniffi/size.ts +++ b/test/wasm/uniffi/size.ts @@ -28,7 +28,21 @@ function name(bytes: Uint8Array, at: { pos: number }): string { return text; } -const SECTIONS = ["custom", "type", "import", "function", "table", "memory", "global", "export", "start", "element", "code", "data", "datacount"]; +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 { @@ -40,7 +54,7 @@ function crate(fn: string): string { return "(other)"; } -function analyze(file: string, bytes: Uint8Array) { +function analyze(file: string, bytes: Uint8Array) { const at = { pos: 8 }; const sections: Record = {}; const bodies: number[] = []; @@ -126,7 +140,8 @@ function analyze(file: string, bytes: Uint8Array) { } const reports = []; -for (const file of process.argv.slice(2)) reports.push(analyze(file, new Uint8Array(await Bun.file(file).arrayBuffer()))); +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 { diff --git a/test/wasm/uniffi/tsconfig.json b/test/wasm/uniffi/tsconfig.json index 444e6e04209..14cecbbc711 100644 --- a/test/wasm/uniffi/tsconfig.json +++ b/test/wasm/uniffi/tsconfig.json @@ -4,5 +4,5 @@ "types": ["bun"], "noEmit": true }, - "include": ["cases.ts", "check.ts", "driver.ts"] + "include": ["cases.ts", "check.ts", "driver.ts", "size.ts"] } From 7b661785eb8a67451271c62ee7231342f821ab2a Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:22:22 -0700 Subject: [PATCH 06/10] test(wasm): drop the unused relays argument from local cases Co-Authored-By: Claude Opus 5.5 --- test/wasm/uniffi/cases.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts index 7a86283f4de..e2666048cef 100644 --- a/test/wasm/uniffi/cases.ts +++ b/test/wasm/uniffi/cases.ts @@ -115,9 +115,7 @@ function localGroup(sequence = 0n) { return { broadcast, track, group, consumer: group.consume() }; } -type Case = (relays: Relay[]) => Promise; - -const local: Record = { +const local: Record Promise> = { "u64 above 2^53 round-trips locally": async () => { for (const sequence of HIGH) { const { group, consumer } = localGroup(sequence); @@ -385,7 +383,7 @@ async function run(relays: Relay[]): Promise { 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(relays)); + 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)); From df3b55f460cecf5849a4d1844c137473f1331621 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:05:35 -0700 Subject: [PATCH 07/10] test(wasm): run the BoltFFI slice end to end on the baseline harness browser.patch gives bench/ffi-gen's slice a WebTransport client, so both generators bind identical Rust and join the baseline sweep with parity cases. Co-Authored-By: Claude Opus 5.5 --- test/wasm/README.md | 17 +- test/wasm/baseline/consumer.ts | 36 +++ test/wasm/baseline/driver.ts | 44 +++- test/wasm/baseline/fixture.ts | 2 +- test/wasm/generator.md | 281 +++++++++++---------- test/wasm/run.sh | 7 +- test/wasm/slice/adapter.ts | 49 +++- test/wasm/slice/bench.ts | 7 +- test/wasm/slice/boltffi.ts | 69 +++++- test/wasm/slice/browser.patch | 431 +++++++++++++++++++++++++++++++++ test/wasm/slice/build.sh | 19 +- test/wasm/slice/cases.ts | 286 ++++++++++++++++++++++ test/wasm/slice/driver.ts | 65 ++++- test/wasm/slice/tsconfig.json | 2 +- test/wasm/slice/uniffi.ts | 76 +++++- test/wasm/uniffi/cases.ts | 34 ++- 16 files changed, 1237 insertions(+), 188 deletions(-) create mode 100644 test/wasm/slice/browser.patch create mode 100644 test/wasm/slice/cases.ts diff --git a/test/wasm/README.md b/test/wasm/README.md index de377ae1606..c6e2aa5e81d 100644 --- a/test/wasm/README.md +++ b/test/wasm/README.md @@ -176,13 +176,14 @@ API parity remain explicit gaps too. ## Generated bindings ```bash -just test wasm-baseline --uniffi -just test wasm --slice +just test wasm-baseline --uniffi --slice +just test wasm --uniffi --slice ``` -`--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. `--slice` times the -`bench/ffi-gen` slice under UniFFI and BoltFFI. Both build from scratch copies, since -ubrn needs a UniFFI pin that does not land. [generator.md](generator.md) is the -resulting report. +`--uniffi` adds moq-ffi, bound by UniFFI over ubrn's `wasm2` player, as a baseline +backend. It also runs parity cases and per-call timings in `uniffi/` against every relay, +once normally and once under a strict CSP. `--slice` does the same for the +`bench/ffi-gen` slice (from #5736) bound by UniFFI and by BoltFFI, with +`slice/browser.patch` adding the WebTransport client the slice lacks. Both build from +scratch copies, since ubrn needs a UniFFI pin that does not land. +[generator.md](generator.md) is the resulting report. diff --git a/test/wasm/baseline/consumer.ts b/test/wasm/baseline/consumer.ts index 0ff9b378e3d..ac6e245651c 100644 --- a/test/wasm/baseline/consumer.ts +++ b/test/wasm/baseline/consumer.ts @@ -1,6 +1,7 @@ import { Milli } from "@moq/clock"; import type * as Moq from "@moq/net"; import type * as Wasm from "@moq/wasm"; +import type { Open } from "../slice/adapter.ts"; import { type Sample, verify } from "./fixture.ts"; import type { Uniffi } from "./uniffi.ts"; @@ -144,6 +145,41 @@ async function run(config: Sample) { }), ); }; + } else if (config.backend === "slice-uniffi" || config.backend === "slice-boltffi") { + if (config.transport !== "webtransport") + throw new Error("the slice's browser client has no WebSocket fallback"); + const generator = config.backend.slice("slice-".length); + const memoryOf = captureMemory(); + const { open } = (await import(config.module)) as { open: Open }; + const slice = await open(new URL(`slice/${generator}.wasm`, config.module)); + memory = memoryOf(); + startup = performance.now() - start; + const { url, hex } = await pin(config.url); + read = async () => { + const session = await slice.connect(url.toString(), hex, config.version); + disposers.push(() => session.cancel()); + await Promise.all( + Array.from({ length: config.publishers }, async (_, publisher) => { + const broadcast = await session.broadcast(`${config.id}/${publisher}`); + const track = await broadcast.subscribe("data"); + const group = await track.recvGroup(); + if (group.sequence() !== BigInt(config.sequence)) + throw new Error("incorrect slice group sequence"); + for (let frame = 0; frame < config.frames; frame++) { + const got = await group.read(); + if ( + !got || + (config.version === "moq-lite-02" + ? got.timestampUs !== undefined + : got.timestampUs !== BigInt(frame * 1000)) + ) + throw new Error(`incorrect slice frame timestamp ${frame}`); + check(got.payload, frame); + } + if (await group.read()) throw new Error("unexpected extra slice frame"); + }), + ); + }; } else { if (config.transport !== "webtransport") throw new Error("moq-ffi's wasm client has no WebSocket fallback"); const memoryOf = captureMemory(); diff --git a/test/wasm/baseline/driver.ts b/test/wasm/baseline/driver.ts index ad44b95723d..1ccb0e5bf07 100644 --- a/test/wasm/baseline/driver.ts +++ b/test/wasm/baseline/driver.ts @@ -22,6 +22,12 @@ 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"); +// The bench/ffi-gen slice with browser.patch, under each generator; built by slice/build.sh. +const slice = process.env.MOQ_WASM_SLICE === "1"; +const sliceModules: Record = { + "/slice/uniffi.wasm": join(root, "test/wasm/slice/dist/uniffi/moq_ffi_gen.wasm"), + "/slice/boltffi.wasm": join(root, "test/wasm/slice/dist/boltffi/moq_ffi_gen_bg.wasm"), +}; const relays = (await Bun.file(values.relays).json()) as RelayFixture[]; if (!relays.length) throw new Error("no relay fixtures"); const server = Bun.serve({ @@ -37,7 +43,7 @@ const server = Bun.serve({ ? Bun.file(join(wasm, path.slice(6))) : path.startsWith("/uniffi/") ? Bun.file(join(generated, path.slice(8))) - : Bun.file(join(dist, path)); + : Bun.file(sliceModules[path] ?? join(dist, path)); return (await file.exists()) ? new Response(file, { headers: { "cache-control": "no-store" } }) : new Response("not found", { status: 404 }); @@ -78,6 +84,9 @@ const report = { 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", + slice: slice + ? "bench/ffi-gen at 09ff0b861 plus slice/browser.patch; UniFFI 0.31 over ubrn wasm2 and BoltFFI 0.31.1; wasm-release; bundled with bun --minify" + : "not run", }, tools: { rust: command("rustc", "--version"), @@ -98,6 +107,13 @@ const report = { 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"))] : [], + // Each adapter bundle holds its generated bindings and runtime; the adapter is a few hundred bytes. + "slice-uniffi": slice + ? [await asset(join(dist, "slice-uniffi.js")), await asset(sliceModules["/slice/uniffi.wasm"])] + : [], + "slice-boltffi": slice + ? [await asset(join(dist, "slice-boltffi.js")), await asset(sliceModules["/slice/boltffi.wasm"])] + : [], harness: [await asset(join(dist, "consumer.js")), await asset(join(dist, "publisher.js"))], }, limits: [ @@ -116,10 +132,18 @@ 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(); -// Alternating and consecutive A/A rounds, so drift on a shared host hits every backend. -const order: Sample["backend"][] = uniffi - ? ["js", "js", "wasm", "wasm", "uniffi", "uniffi", "js", "wasm", "uniffi", "uniffi", "wasm", "js"] - : ["js", "js", "wasm", "wasm", "js", "wasm"]; +// 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) : []), + ...(slice ? (["slice-uniffi", "slice-boltffi"] 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) { @@ -157,7 +181,15 @@ try { version: relay.version, backend, transport: "webtransport", - module: `${endpoint}/${{ js: "net.js", wasm: "wasm/moq.js", uniffi: "uniffi.js" }[backend]}`, + module: `${endpoint}/${ + { + js: "net.js", + wasm: "wasm/moq.js", + uniffi: "uniffi.js", + "slice-uniffi": "slice-uniffi.js", + "slice-boltffi": "slice-boltffi.js", + }[backend] + }`, }; await sample(config, round); } diff --git a/test/wasm/baseline/fixture.ts b/test/wasm/baseline/fixture.ts index c3763a7d4d3..2c1a1014178 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" | "uniffi"; + backend: "js" | "wasm" | "uniffi" | "slice-uniffi" | "slice-boltffi"; transport: "webtransport" | "websocket"; module: string; grow: boolean; diff --git a/test/wasm/generator.md b/test/wasm/generator.md index c9c014722aa..f0cdbc2b3fa 100644 --- a/test/wasm/generator.md +++ b/test/wasm/generator.md @@ -1,17 +1,21 @@ # Browser binding generator report Which Rust binding `@moq/net` wraps in the browser: moq-ffi through UniFFI (ubrn's -`wasm2` player), the moq-ffi slice through BoltFFI, or the hand-written wasm-bindgen -`rs/moq-wasm`. This compares them on the [baseline harness](README.md#backend-baseline). -It adopts nothing. +`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). It adopts +nothing. + +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: open, see the PR that added this file. ## Reproduce ```bash -just test wasm-baseline --uniffi # moq-ffi over ubrn: parity cases, then the baseline sweep with a third backend -just test wasm --slice # the slice's per-call costs under UniFFI and BoltFFI (needs bench/ffi-gen, #5736) +just test wasm-baseline --uniffi --slice # every backend in the baseline sweep, plus parity cases and per-call timings +just test wasm --uniffi --slice # the parity cases and per-call timings alone bun test/wasm/uniffi/size.ts ... # sections and per-crate code share ``` @@ -19,16 +23,22 @@ 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. The patch also carries a browser fix moq-ffi needs (see [Bugs found](#bugs-found)). moq-ffi built on 0.31 with -no other source change. [`slice/build.sh`](slice/build.sh) does the same to the slice. +no other source change. + +[`slice/build.sh`](slice/build.sh) copies `bench/ffi-gen` (from +[#5736](https://github.com/moq-dev/moq/pull/5736)), applies +[`slice/browser.patch`](slice/browser.patch), and binds it with both generators. The +patch adds a `browser` feature: a WebTransport session that consumes and publishes, like +moq-ffi's wasm client, plus `create_group` for high sequences. ## 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 -13 to 25 during the sweep), so treat absolute numbers as noisy. Every comparison -interleaves its candidates: the sweep runs js, js, wasm, wasm, uniffi, uniffi, js, wasm, -uniffi, uniffi, wasm, js per shape, and the slice runs ABBA rounds on fresh pages. Cells -are medians with the min to max range. +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. @@ -36,21 +46,23 @@ All builds use `wasm-release` (opt-level z, fat LTO) and no wasm-opt, as moq-was Shipped assets, as each backend's page loads them. gzip is Bun's default level. -| backend | `.wasm` | `.wasm` gzip | JS | JS gzip | total gzip | -|---|---|---|---|---|---| -| `@moq/net` (JS) | - | - | 386,552 | 109,486 | 109,486 | -| moq-wasm, wasm-bindgen | 2,194,698 | 730,348 | 39,052 | 7,782 | 738,130 | -| moq-ffi, UniFFI | 6,408,963 | 1,903,175 | 239,899 | 34,751 | 1,937,926 | -| moq-ffi, UniFFI, no custom sections | 4,533,099 | 1,667,925 | 239,899 | 34,751 | 1,702,676 | -| slice, UniFFI | 1,278,308 | 257,617 | 65,628 | 15,853 | 273,470 | -| slice, BoltFFI | 1,175,797 | 231,257 | 53,761 | 11,987 | 243,244 | +| 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 | -UniFFI's JS is the generated bindings, `@ubjs/core` and `@ubjs/wasm`, and wasm-bindgen's -glue, minified. ubrn stages the module with its `name` section; wasm-bindgen drops it for -moq-wasm, which is worth 235 KB gzip. +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. -moq-ffi binds the whole surface, so the `.wasm` is 2.3 times moq-wasm even stripped. Its -code section (3.79 MB) by crate, attributing a generic to the crate its path starts with: +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 | |---|---|---|---| @@ -69,139 +81,163 @@ drop most of them. Toggling `default-features` changes nothing on wasm32. 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. +32 frames per subscriber. This sweep ran at load average 29 to 32. -| | JS | moq-wasm | UniFFI | -|---|---|---|---| -| module import and init | 29 ms (26-33) | 21 ms (18-24) | 61 ms (58-69) | -| first frame | 74 ms (60-79) | 79 ms (68-87) | 118 ms (104-130) | -| wasm memory before reads | - | 1.31 MiB | 1.81 MiB | -| JS heap after reads | 3.33 MiB | 2.06 MiB | 3.62 MiB | +| | 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 | UniFFI | +| 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 | |---|---|---|---| -| lite-06, 160 B, 1x1 | 1.56 (1.47-1.70) | 2.33 (2.17-2.44) | 3.13 (3.06-3.37) | -| lite-06, 160 B, 4x4 | 0.21 (0.21-0.22) | 0.26 (0.25-0.26) | 0.31 (0.29-0.31) | -| lite-06, 4 KiB, 1x4 | 0.57 (0.54-0.61) | 0.73 (0.72-0.76) | 0.97 (0.95-0.98) | -| lite-06, 128 KiB, 1x1 | 2.09 (2.03-2.14) | 3.32 (3.15-3.54) | 4.67 (4.63-4.74) | -| lite-06, 128 KiB, 1x4 | 0.97 (0.93-0.97) | 1.28 (1.23-1.31) | 2.03 (1.96-2.07) | -| lite-06, 128 KiB, 4x4 | 1.44 (0.87-1.65) | 1.69 (1.18-2.17) | 2.23 (1.84-2.77) | -| transport-19, 128 KiB, 1x4 | 1.24 (1.07-1.67) | 1.48 (1.41-1.68) | 2.20 (2.06-2.26) | -| lite-02, 128 KiB, 1x4 | 1.03 (0.91-1.16) | 1.29 (1.27-1.35) | 2.11 (2.06-2.22) | - -The raw samples for every shape, including the u32 and near-2^53 sequences, are in the -run's `backend.json`. UniFFI costs 1.2 to 1.6 times moq-wasm's CPU per frame here, more -as frames grow, since every payload crosses as a record copied byte by byte. +| 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. +call, from one session at load average 12 to 20. The slice, six ABBA rounds: | call | UniFFI | BoltFFI | |---|---|---| -| sync `noop()` | 0.65 (0.33-0.74) | 0.03 (0.02-0.04) | -| sync getter `sequence()` | 0.33 (0.27-0.38) | 0.04 (0.01-0.06) | -| async `noop_async()` | 2.33 (2.28-2.62) | 1.50 (1.00-1.74) | -| async `yield_once()`: one wakeup | 3.11 (3.00-3.62) | 1.79 (1.64-2.04) | -| `write_frame` 160 B | 1.64 (1.58-1.73) | 0.76 (0.72-0.85) | -| `write_frame` 4 KiB | 13.6 (13.3-13.8) | 2.26 (2.04-2.35) | -| `write_frame` 128 KiB | 401 (395-420) | 58.5 (51.8-63.2) | -| `read_frame`, buffered, 160 B | 5.47 (5.29-5.69) | 2.57 (2.34-3.28) | -| `read_frame`, buffered, 4 KiB | 38.5 (37.5-39.0) | 8.08 (7.07-8.62) | -| `read_frame`, buffered, 128 KiB | 1,052 (1,039-1,079) | 141 (124-146) | -| `read_frame`, parked then woken, 160 B | 7.19 (6.70-7.82) | 3.75 (3.40-4.00) | - -UniFFI resolves one JS Promise per `rust_future_poll` wakeup, as BoltFFI does; the -async gap is about 2 times. Its bytes cost about 3 ns per byte written and 8 ns per byte -read, from UniFFI's per-element `Vec` lift and lower in Rust plus a JS-side copy, -against BoltFFI's bulk copies. 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, one run per mode (the strict CSP below): +| 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 | 52.7 ms | 46.4 ms | -| sync getter `sequence()` | 0.34 | 0.89 | -| `write_frame` 160 B | 2.39 | 3.58 | -| `write_frame` 128 KiB | 883 | 933 | -| `read_frame`, buffered, 160 B | 6.41 | 8.54 | -| `read_frame`, parked then woken, 160 B | 9.70 | 9.00 | - -The slice's `sequence()` matches moq-ffi's. Compare the frame rows across these two tables -only roughly: they ran at different times on the shared host, and moq-ffi's frames carry -more bookkeeping than the slice's. +| 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) runs against all three relays, in both dispatch modes. -moq-wasm's column is the existing suite in [`src/main.ts`](src/main.ts). +[`uniffi/cases.ts`](uniffi/cases.ts) and [`slice/cases.ts`](slice/cases.ts) run against +all three relays, normally and under a strict CSP. Every case passes 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 | -|---|---|---| -| negotiates each relay's version | yes, via `version()` | yes, by offering only that version: moq-ffi exposes no negotiated version | -| reads a published broadcast, every byte, 128 KiB frames | yes | yes | -| refuses a missing track | yes | yes: rejects with `NotFound` (transport-19) or `Protocol` (lite) | -| publishes | no: consume only | yes | -| frame timestamps | no: payload only | yes, exact, as `bigint` microseconds | -| 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) | -| cancellation | `free()` rejects pending calls | every async method takes `{ signal }`; abort rejects with `AbortError` and the next call still gets the next frame | -| aborting releases the call's state | n/a | yes: a track's demand drops only once the aborted future is freed | -| freeing a handle mid-call | rejects the call | the call keeps running: each call clones the `Arc`; `cancel()` is the terminal | -| close and error propagation | yes | session `cancel()` rejects a pending remote read with `Transport` | -| forced wasm memory growth mid-read | yes | yes | -| WebSocket fallback | no | no: moq-ffi's wasm client has none either | - -Things the binding cannot 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. The generated `*Like` return types omit `uniffiDestroy`, so a -caller casts to the class to free a handle early, and a `FinalizationRegistry` frees the -rest. +| 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. Every case passes in both modes. Sync - calls cost up to 2.6 times as much interpreted; frame calls barely move. -- **Bundlers.** `uniffiInitAsync` takes the `.wasm` URL from the host. The `.wasm` and - its wasm-bindgen `_bg.js` are a matched pair. The generated `index.ts` imports - `@ubjs/wasm`, which resolves its browser build through package `exports`. -- **Types.** The generated TypeScript carries `@ts-nocheck`, so + 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. The harness type-checks its callers against it under the repo's strict config. + 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). -- **JS only.** The player cannot drive wasm2js output as is: it compiles small wasm + 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; an input to the [WASM-to-JS evaluation](/quest/m2/rs2ts-wasm2js.md). + Not attempted for either generator; an input to the + [WASM-to-JS evaluation](/quest/m2/rs2ts-wasm2js.md). -## BoltFFI +## What blocks BoltFFI -The slice binds and runs in Chromium under BoltFFI 0.31.1, and is the fastest on every -call. That passes the feasibility filter, but it is not selection evidence: +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: -- BoltFFI cannot bind moq-ffi as written. [#5736](https://github.com/moq-dev/moq/pull/5736) - lists eleven gaps, including `Arc` returns, ignored target `cfg`, and `Send + Sync` - on wasm. -- The slice has no client on wasm, because BoltFFI's scanner ignores target `cfg` and - web-transport-wasm is gated on it. So no network parity, size, startup, or memory - numbers exist for it. -- \#5736 recommends forking UniFFI for the native targets. Picking BoltFFI for the browser - alone would carry two generators. - -If BoltFFI is favored anyway, the quest's next step stands: port the harness workload to it -and rerun parity, size, startup, and memory before picking it. +| 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 @@ -211,3 +247,4 @@ and rerun parity, size, startup, and memory before picking it. its own; the real fix belongs to the bindings parity work. - **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. diff --git a/test/wasm/run.sh b/test/wasm/run.sh index e0263b061a9..348f7bc9805 100755 --- a/test/wasm/run.sh +++ b/test/wasm/run.sh @@ -53,7 +53,8 @@ while [[ $# -gt 0 ]]; do shift ;; --slice) - # bench/ffi-gen's slice bound by UniFFI and BoltFFI, for per-call costs. + # bench/ffi-gen's slice bound by UniFFI and BoltFFI: parity cases, per-call + # costs, and (with --baseline) two more baseline backends. SLICE=true shift ;; @@ -156,12 +157,14 @@ if [[ -n $BASELINE ]]; then bun build uniffi/dist/index.ts --outfile dist/uniffi.js --target browser --minify >/dev/null export MOQ_WASM_UNIFFI=1 fi + [[ -z $SLICE ]] || export MOQ_WASM_SLICE=1 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 if [[ -n $SLICE ]]; then + bun build slice/cases.ts --outfile dist/slice-cases.js --target browser >/dev/null bun build slice/bench.ts --outfile dist/slice-bench.js --target browser >/dev/null for generator in uniffi boltffi; do bun build "slice/$generator.ts" --outfile "dist/slice-$generator.js" --target browser --minify >/dev/null @@ -222,7 +225,7 @@ if [[ $status -eq 0 && -n $UNIFFI ]]; then harness_wait "$HARNESS_PID" || status=$? fi if [[ $status -eq 0 && -n $SLICE ]]; then - harness_spawn slice - bun slice/driver.ts + harness_spawn slice - bun slice/driver.ts --relays "$HARNESS_RUN/relays.json" harness_wait "$HARNESS_PID" || status=$? fi diff --git a/test/wasm/slice/adapter.ts b/test/wasm/slice/adapter.ts index a0340b0d21e..2d97551d114 100644 --- a/test/wasm/slice/adapter.ts +++ b/test/wasm/slice/adapter.ts @@ -1,15 +1,54 @@ -/** What the slice bench needs from either generator, so one page drives both. */ +/** What the slice harness needs from either generator, so one page drives both. */ export interface Slice { noop(): void; noopAsync(): Promise; yieldOnce(): Promise; - /** A fresh standalone group, a consumer of it, and a way to free both. */ - group(): Group; + /** A fresh standalone group at `sequence` (default: the next), and a consumer of it. */ + group(sequence?: bigint): Group; + /** Dial `url` trusting only the hex SHA-256 `fingerprint`, offering only `version`. */ + connect(url: string, fingerprint: string, version: string): Promise; +} + +/** A frame as both generators hand it back, payload copied out of Rust. */ +export interface Frame { + payload: Uint8Array; + timestampUs?: bigint; } export interface Group { sequence(): bigint; - write(bytes: Uint8Array): void; - read(): Promise; + write(bytes: Uint8Array, timestampUs?: bigint): void; + read(signal?: AbortSignal): Promise; + /** Free the consumer's handle, leaving any pending call to the generator. */ + free(): void; close(): void; } + +export interface Session { + /** Wait until `path` is announced. */ + broadcast(path: string): Promise; + /** Announce a broadcast at `path` with one microsecond-timed track, created first. */ + publish(path: string): Publisher; + cancel(): void; +} + +export interface Broadcast { + subscribe(name: string): Promise; +} + +export interface Track { + /** The next group; rejects once the track ends. */ + recvGroup(signal?: AbortSignal): Promise; +} + +export interface RemoteGroup { + sequence(): bigint; + read(signal?: AbortSignal): Promise; +} + +export interface Publisher { + group(sequence: bigint): { write(bytes: Uint8Array, timestampUs: bigint): void; finish(): void }; +} + +/** Each adapter bundle's one export. */ +export type Open = (wasm: URL) => Promise; diff --git a/test/wasm/slice/bench.ts b/test/wasm/slice/bench.ts index ca2c519fb6b..b8ba3c504ad 100644 --- a/test/wasm/slice/bench.ts +++ b/test/wasm/slice/bench.ts @@ -5,7 +5,7 @@ * * @module */ -import type { Slice } from "./adapter.ts"; +import type { Open } from "./adapter.ts"; export interface Bench { name: string; @@ -25,7 +25,7 @@ const BATCH = 1000; async function run(generator: string): Promise { const start = performance.now(); // Runtime URLs keep each generator's bundle out of the other's page. - const { open } = (await import(`/slice-${generator}.js`)) as { open(wasm: URL): Promise }; + const { open } = (await import(`/slice-${generator}.js`)) as { open: Open }; const slice = await open(new URL(`/slice/${generator}.wasm`, location.href)); const init_ms = performance.now() - start; @@ -74,7 +74,8 @@ async function run(generator: string): Promise { const group = slice.group(); for (let i = 0; i < per; i++) group.write(bytes); const start = performance.now(); - for (let i = 0; i < per; i++) if ((await group.read()) !== size) throw new Error("wrong frame size"); + for (let i = 0; i < per; i++) + if ((await group.read())?.payload.byteLength !== size) throw new Error("wrong frame size"); const elapsed = performance.now() - start; group.close(); return elapsed; diff --git a/test/wasm/slice/boltffi.ts b/test/wasm/slice/boltffi.ts index 3cdb3f007c8..15ac14a08e6 100644 --- a/test/wasm/slice/boltffi.ts +++ b/test/wasm/slice/boltffi.ts @@ -1,24 +1,73 @@ -// The slice bound by BoltFFI, behind the bench's adapter. -import type { Slice } from "./adapter.ts"; -import init, { MoqBroadcastProducer, noop, noopAsync, yieldOnce } from "./dist/boltffi/moq_ffi_gen.js"; +// The slice bound by BoltFFI, behind the harness adapter. +import type { Frame, Open, RemoteGroup } from "./adapter.ts"; +import init, { + MoqBroadcastProducer, + MoqBrowserSession, + type MoqFrame, + type MoqGroupConsumer, + noop, + noopAsync, + yieldOnce, +} from "./dist/boltffi/moq_ffi_gen.js"; -export async function open(wasm: URL): Promise { +const lift = (frame: MoqFrame | null): Frame | undefined => + frame ? { payload: frame.payload, timestampUs: frame.timestampUs ?? undefined } : undefined; +const opts = (signal?: AbortSignal) => (signal ? { signal } : undefined); +const remote = (group: MoqGroupConsumer): RemoteGroup => ({ + sequence: () => group.sequence(), + read: async (signal) => lift(await group.readFrame(opts(signal))), +}); + +export const open: Open = async (wasm) => { await init(await fetch(wasm)); return { noop, noopAsync: () => noopAsync(), yieldOnce: () => yieldOnce(), - group() { + group(sequence) { const broadcast = MoqBroadcastProducer.new(); const track = broadcast.publishTrack("data"); - const group = track.appendGroup(); + const group = sequence === undefined ? track.appendGroup() : track.createGroup(sequence); const consumer = group.consume(); return { sequence: () => consumer.sequence(), - write: (bytes) => group.writeFrame({ payload: bytes, timestampUs: 0n }), - read: async () => (await consumer.readFrame())?.payload.byteLength, - close: () => track.finish(), + write: (bytes, timestampUs = 0n) => group.writeFrame({ payload: bytes, timestampUs }), + read: async (signal) => lift(await consumer.readFrame(opts(signal))), + free: () => consumer.dispose(), + close: () => { + group.finish(); + track.finish(); + }, + }; + }, + async connect(url, fingerprint, version) { + // BoltFFI types an async constructor's result as nullable (boltffi#95). + const session = await MoqBrowserSession.connect(url, fingerprint, version); + if (!session) throw new Error("BoltFFI's async constructor returned null"); + return { + async broadcast(path) { + const broadcast = await session.announcedBroadcast(path); + return { + async subscribe(name) { + const track = await broadcast.subscribeTrack(name); + return { recvGroup: async (signal) => remote(await track.recvGroup(opts(signal))) }; + }, + }; + }, + publish(path) { + const track = session.publishTrack(path, "data"); + return { + group(sequence) { + const group = track.createGroup(sequence); + return { + write: (bytes, timestampUs) => group.writeFrame({ payload: bytes, timestampUs }), + finish: () => group.finish(), + }; + }, + }; + }, + cancel: () => session.cancel(), }; }, }; -} +}; diff --git a/test/wasm/slice/browser.patch b/test/wasm/slice/browser.patch new file mode 100644 index 00000000000..ab87a5285d7 --- /dev/null +++ b/test/wasm/slice/browser.patch @@ -0,0 +1,431 @@ +diff --git a/boltffi.toml b/boltffi.toml +--- a/boltffi.toml ++++ b/boltffi.toml +@@ -30,8 +30,8 @@ + output = "../../target/ffi-gen/boltffi/wasm" + # Only `debug` or `release`: moq-wasm's size-tuned `wasm-release` is not selectable. + profile = "release" +-# No `client`: the browser dials through web-transport-wasm, outside the slice. +-cargo_args = ["--no-default-features"] ++# No `client`: the browser dials through `browser` instead. ++cargo_args = ["--no-default-features", "--features=browser"] + + # moq-wasm ships without wasm-opt, and the dev shell has none. + [targets.wasm.optimize] +diff --git a/Cargo.toml b/Cargo.toml +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -19,10 +19,12 @@ + # Pick exactly one generator per build. + uniffi = ["dep:uniffi", "dep:moq-ffi-gen-uniffi-shim"] + boltffi = ["dep:boltffi"] +-# `MoqClient` and `MoqSession`, native only: the browser dials through +-# web-transport-wasm, which the slice leaves out. A feature rather than a target `cfg`, ++# `MoqClient` and `MoqSession`, native only: the browser dials through `browser`, over ++# web-transport-wasm. A feature rather than a target `cfg`, + # because BoltFFI's macros never see the target, so `cfg(target_arch)` reads as unset. + client = ["dep:moq-tokio", "dep:url"] ++# `MoqBrowserSession`, wasm only: dials through web-transport-wasm like moq-ffi's wasm client. ++browser = ["dep:bytes", "dep:url", "dep:web-transport-trait", "dep:web-transport-wasm"] + + [dependencies] + boltffi = { version = "=0.31.1", optional = true } +@@ -44,6 +46,10 @@ + # As moq-ffi: drops the `Send` bounds moq-net's wasm futures and handles cannot meet. + uniffi = { version = "0.32", optional = true, features = ["wasm-unstable-single-threaded"] } + web-async = "0.1.5" ++bytes = { version = "1", optional = true } ++url = { version = "2", optional = true } ++web-transport-trait = { version = "0.5", optional = true } ++web-transport-wasm = { version = "0.8", optional = true } + + [dev-dependencies] + libc = "0.2" +diff --git a/src/browser.rs b/src/browser.rs +--- a/src/browser.rs ++++ b/src/browser.rs +@@ -0,0 +1,159 @@ ++//! Connecting to a relay from the browser, like moq-ffi's wasm client: WebTransport through ++//! web-transport-wasm, pinned to a certificate hash, offering one version. ++//! ++//! A `browser` feature rather than `cfg(target_arch)`, because BoltFFI's scanner ignores ++//! target cfgs (see `client`). ++ ++#[cfg(feature = "boltffi")] ++use boltffi::export; ++#[cfg(feature = "uniffi")] ++use moq_ffi_gen_uniffi_shim::export; ++ ++use std::sync::Mutex; ++ ++use crate::{MoqBroadcastProducer, MoqError, MoqTrackConsumer, MoqTrackProducer, spawn}; ++ ++#[cfg(target_arch = "wasm32")] ++type Transport = crate::browser_transport::Session; ++ ++fn net(err: impl std::fmt::Display) -> MoqError { ++ MoqError::Net { ++ reason: err.to_string(), ++ } ++} ++ ++/// A browser session: publishes and consumes through two origins of its own. ++#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] ++pub struct MoqBrowserSession { ++ session: Mutex>, ++ publish: moq_net::origin::Producer, ++ broadcasts: Mutex>, ++ consume: moq_net::origin::Consumer, ++} ++ ++#[export] ++impl MoqBrowserSession { ++ /// Dial `url`, trusting only the hex SHA-256 `fingerprint` and offering only `version`. ++ #[cfg_attr(feature = "uniffi", uniffi::constructor)] ++ pub async fn connect(url: String, fingerprint: String, version: String) -> Result { ++ SingleThreaded(Self::dial(url, fingerprint, version)).await ++ } ++ ++ /// Wait until `path` is announced, then return it. ++ pub async fn announced_broadcast(&self, path: String) -> Result { ++ let broadcast = SingleThreaded(self.consume.routed_broadcast(path.as_str())) ++ .await ++ .map_err(net)?; ++ Ok(MoqBroadcastConsumer { inner: broadcast }) ++ } ++ ++ /// Publish a microsecond-timed track `name` in a new broadcast at `path`, then announce it. ++ /// ++ /// Announcing after the track exists, as moq-ffi documents, so a subscriber that races ++ /// the announcement never finds the broadcast without its track. ++ pub fn publish_track(&self, path: String, name: String) -> Result { ++ let broadcast = MoqBroadcastProducer::from_inner(self.publish.create_broadcast(path.as_str())?); ++ let track = broadcast.publish_track(name)?; ++ broadcast.inner.announce(moq_net::origin::Route::default())?; ++ // The session owns the broadcast: dropping its producer would end it. ++ self.broadcasts.lock().unwrap().push(broadcast); ++ Ok(track) ++ } ++ ++ /// Close the session; pending calls on it fail. ++ pub fn cancel(&self) { ++ if let Some(session) = self.session.lock().unwrap().take() { ++ session.abort(moq_net::Error::Cancel); ++ } ++ } ++} ++ ++impl MoqBrowserSession { ++ // BoltFFI reads binding metadata from a host build, where none of this exists. ++ #[cfg(not(target_arch = "wasm32"))] ++ async fn dial(_url: String, _fingerprint: String, _version: String) -> Result { ++ Err(net("the browser client runs only on wasm32")) ++ } ++ ++ #[cfg(target_arch = "wasm32")] ++ async fn dial(url: String, fingerprint: String, version: String) -> Result { ++ let url = url::Url::parse(&url).map_err(net)?; ++ let version: moq_net::Version = version.parse().map_err(net)?; ++ let versions = moq_net::Versions::from(vec![version]); ++ let hash = (0..fingerprint.len()) ++ .step_by(2) ++ .map(|i| u8::from_str_radix(fingerprint.get(i..i + 2).unwrap_or("zz"), 16)) ++ .collect::, _>>() ++ .map_err(net)?; ++ ++ let transport = web_transport_wasm::ClientBuilder::new() ++ .with_protocols(versions.alpns()) ++ .with_server_certificate_hashes(vec![hash]) ++ .connect(url) ++ .await ++ .map(Transport::new) ++ .map_err(net)?; ++ ++ let (publish, publish_driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); ++ let (subscribe, subscribe_driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); ++ spawn(async move { ++ moq_net::time::run(publish_driver).await; ++ }); ++ spawn(async move { ++ moq_net::time::run(subscribe_driver).await; ++ }); ++ let consume = subscribe.consume(); ++ ++ let (session, driver) = moq_net::Client::new() ++ .with_versions(versions) ++ .with_publisher(&publish.consume()) ++ .with_subscriber(subscribe) ++ .connect(web_async::time::Instant::now(), transport) ++ .await ++ .map_err(net)?; ++ spawn(async move { ++ moq_net::time::run(driver).await; ++ }); ++ ++ Ok(Self { ++ session: Mutex::new(Some(session)), ++ publish, ++ broadcasts: Mutex::default(), ++ consume, ++ }) ++ } ++} ++ ++/// Asserts a future is `Send`. BoltFFI requires `Send` futures on every target, but the ++/// browser's WebTransport handles are `Rc`s; wasm32 without threads never moves them. ++/// UniFFI's `wasm-unstable-single-threaded` drops the same bound. ++struct SingleThreaded(F); ++ ++// SAFETY: wasm32-unknown-unknown without the atomics feature has one thread. ++unsafe impl Send for SingleThreaded {} ++ ++impl std::future::Future for SingleThreaded { ++ type Output = F::Output; ++ ++ fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll { ++ // SAFETY: structural pinning; the field is never moved out. ++ unsafe { self.map_unchecked_mut(|this| &mut this.0) }.poll(cx) ++ } ++} ++ ++/// A broadcast announced by the relay. ++#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] ++pub struct MoqBroadcastConsumer { ++ inner: moq_net::broadcast::Consumer, ++} ++ ++#[export] ++impl MoqBroadcastConsumer { ++ /// Subscribe to the track `name`. ++ pub async fn subscribe_track(&self, name: String) -> Result { ++ let track = SingleThreaded(self.inner.track(&name)?.subscribe(None)).await?; ++ Ok(MoqTrackConsumer { ++ inner: Mutex::new(track), ++ }) ++ } ++} +diff --git a/src/browser_transport.rs b/src/browser_transport.rs +--- a/src/browser_transport.rs ++++ b/src/browser_transport.rs +@@ -0,0 +1,166 @@ ++//! moq-ffi's WebTransport adapter at the slice's moq-net revision, minus its tests. ++//! thiserror is swapped for hand-written impls: BoltFFI reads an item-level `#[error]` ++//! as its own error marker. ++ ++use bytes::{Buf, BufMut, Bytes}; ++use moq_net::transport::{self as moq, poll}; ++use std::task::{Context, Poll}; ++use web_transport_trait::{self as wt, poll as backend}; ++ ++/// A backend failure preserving the session and stream code registries. ++#[derive(Debug)] ++pub struct Error(E); ++ ++impl std::fmt::Display for Error { ++ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { ++ self.0.fmt(f) ++ } ++} ++ ++impl std::error::Error for Error { ++ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { ++ self.0.source() ++ } ++} ++ ++impl moq::Error for Error { ++ fn session_error(&self) -> Option<(u32, String)> { ++ self.0.session_error() ++ } ++ fn stream_error(&self) -> Option { ++ self.0.stream_error() ++ } ++} ++ ++struct Stats(S); ++impl moq::Stats for Stats { ++ fn bytes_sent(&self) -> Option { ++ self.0.bytes_sent() ++ } ++ fn bytes_received(&self) -> Option { ++ self.0.bytes_received() ++ } ++ fn bytes_lost(&self) -> Option { ++ self.0.bytes_lost() ++ } ++ fn packets_sent(&self) -> Option { ++ self.0.packets_sent() ++ } ++ fn packets_received(&self) -> Option { ++ self.0.packets_received() ++ } ++ fn packets_lost(&self) -> Option { ++ self.0.packets_lost() ++ } ++ fn rtt(&self) -> Option { ++ self.0.rtt() ++ } ++ fn estimated_send_rate(&self) -> Option { ++ self.0.estimated_send_rate() ++ } ++} ++/// A WebTransport session adapted to moq-net. ++#[derive(Clone)] ++pub struct Session(S); ++impl Session { ++ /// Wrap a backend poll session. ++ pub fn new(session: S) -> Self { ++ Self(session) ++ } ++} ++impl + Clone + 'static> poll::Session for Session { ++ type SendStream = SendStream; ++ type RecvStream = RecvStream; ++ type Error = Error; ++ fn poll_accept_uni(&mut self, cx: &mut Context<'_>) -> Poll> { ++ backend::Session::poll_accept_uni(&mut self.0, cx) ++ .map_err(Error) ++ .map(|res| res.map(RecvStream)) ++ } ++ fn poll_accept_bi(&mut self, cx: &mut Context<'_>) -> Poll, Self::Error>> { ++ backend::Session::poll_accept_bi(&mut self.0, cx) ++ .map_err(Error) ++ .map(|res| res.map(|(send, recv)| (SendStream(send), RecvStream(recv)))) ++ } ++ fn poll_open_uni(&mut self, cx: &mut Context<'_>) -> Poll> { ++ backend::Session::poll_open_uni(&mut self.0, cx) ++ .map_err(Error) ++ .map(|res| res.map(SendStream)) ++ } ++ fn poll_open_bi(&mut self, cx: &mut Context<'_>) -> Poll, Self::Error>> { ++ backend::Session::poll_open_bi(&mut self.0, cx) ++ .map_err(Error) ++ .map(|res| res.map(|(send, recv)| (SendStream(send), RecvStream(recv)))) ++ } ++ fn poll_send_datagram(&mut self, cx: &mut Context<'_>, payload: &[u8]) -> Poll> { ++ backend::Session::poll_send_datagram(&mut self.0, cx, payload).map_err(Error) ++ } ++ fn poll_recv_datagram(&mut self, cx: &mut Context<'_>) -> Poll> { ++ backend::Session::poll_recv_datagram(&mut self.0, cx).map_err(Error) ++ } ++ fn max_datagram_size(&self) -> usize { ++ backend::Session::max_datagram_size(&self.0) ++ } ++ fn binding(&self) -> moq::Binding { ++ moq::Binding::Uri ++ } ++ fn protocol(&self) -> Option<&str> { ++ backend::Session::protocol(&self.0) ++ } ++ fn close(&mut self, code: u32, reason: &str) { ++ backend::Session::close(&mut self.0, code, reason) ++ } ++ fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { ++ backend::Session::poll_closed(&mut self.0, cx).map(Error) ++ } ++ fn stats(&self) -> impl moq::Stats { ++ Stats(backend::Session::stats(&self.0)) ++ } ++} ++/// An outgoing WebTransport stream adapted to moq-net. ++pub struct SendStream(S); ++impl poll::SendStream for SendStream { ++ type Error = Error; ++ fn poll_write(&mut self, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { ++ backend::SendStream::poll_write(&mut self.0, cx, buf).map_err(Error) ++ } ++ fn poll_write_buf(&mut self, cx: &mut Context<'_>, buf: &mut B) -> Poll> { ++ backend::SendStream::poll_write_buf(&mut self.0, cx, buf).map_err(Error) ++ } ++ fn set_priority(&mut self, order: i32) { ++ backend::SendStream::set_priority(&mut self.0, order) ++ } ++ fn finish(&mut self) -> Result<(), Self::Error> { ++ backend::SendStream::finish(&mut self.0).map_err(Error) ++ } ++ fn reset(&mut self, code: u32) { ++ backend::SendStream::reset(&mut self.0, code) ++ } ++ fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { ++ backend::SendStream::poll_closed(&mut self.0, cx).map_err(Error) ++ } ++} ++/// An incoming WebTransport stream adapted to moq-net. ++pub struct RecvStream(S); ++impl poll::RecvStream for RecvStream { ++ type Error = Error; ++ fn poll_read(&mut self, cx: &mut Context<'_>, dst: &mut [u8]) -> Poll, Self::Error>> { ++ backend::RecvStream::poll_read(&mut self.0, cx, dst).map_err(Error) ++ } ++ fn poll_read_buf( ++ &mut self, ++ cx: &mut Context<'_>, ++ buf: &mut B, ++ ) -> Poll, Self::Error>> { ++ backend::RecvStream::poll_read_buf(&mut self.0, cx, buf).map_err(Error) ++ } ++ fn poll_read_chunk(&mut self, cx: &mut Context<'_>, max: usize) -> Poll, Self::Error>> { ++ backend::RecvStream::poll_read_chunk(&mut self.0, cx, max).map_err(Error) ++ } ++ fn stop(&mut self, code: u32) { ++ backend::RecvStream::stop(&mut self.0, code) ++ } ++ fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { ++ backend::RecvStream::poll_closed(&mut self.0, cx).map_err(Error) ++ } ++} +diff --git a/src/client.rs b/src/client.rs +--- a/src/client.rs ++++ b/src/client.rs +@@ -1,5 +1,5 @@ + //! Connecting to a relay: native only, like moq-ffi's QUIC client. The browser connects +-//! through web-transport-wasm, which the slice leaves out. ++//! through web-transport-wasm instead, in `browser`. + + #[cfg(feature = "boltffi")] + use boltffi::export; +diff --git a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -26,6 +26,13 @@ + #[cfg(feature = "client")] + pub use client::*; + ++#[cfg(feature = "browser")] ++mod browser; ++#[cfg(all(feature = "browser", target_arch = "wasm32"))] ++mod browser_transport; ++#[cfg(feature = "browser")] ++pub use browser::*; ++ + // No thiserror: its derive claims BoltFFI's bare `#[error]` marker as its own attribute. + // `reason`, not `message`: UniFFI's Kotlin exception would clash with `Throwable.message`. + /// An error crossing the boundary. +@@ -187,6 +194,17 @@ + }) + } + ++ /// Start a group at `sequence`, for sequences past what JS numbers hold exactly. ++ pub fn create_group(&self, sequence: u64) -> Result { ++ let mut guard = self.inner.lock().unwrap(); ++ let track = guard.as_mut().ok_or(MoqError::Closed)?; ++ let group = track.create_group(moq_net::group::Info { sequence })?; ++ Ok(MoqGroupProducer { ++ sequence, ++ inner: Mutex::new(Some(group)), ++ }) ++ } ++ + /// End the track. + pub fn finish(&self) -> Result<(), MoqError> { + let mut guard = self.inner.lock().unwrap(); +@@ -342,6 +360,9 @@ + MoqTrackConsumer, + MoqGroupConsumer + ); ++ ++ #[cfg(feature = "browser")] ++ assume_single_threaded!(MoqBrowserSession, MoqBroadcastConsumer); + } + + #[cfg(target_arch = "wasm32")] diff --git a/test/wasm/slice/build.sh b/test/wasm/slice/build.sh index f85a9aea1c2..c2c35462b85 100755 --- a/test/wasm/slice/build.sh +++ b/test/wasm/slice/build.sh @@ -2,8 +2,9 @@ # Bind bench/ffi-gen's moq-ffi slice for the browser twice: BoltFFI's web package, and # UniFFI over ubrn's wasm2 player. Outputs land in slice/dist/{boltffi,uniffi}. # -# Works on a scratch copy of the slice, so neither BoltFFI's output nor the UniFFI 0.31 -# pin (ubrn's version; see ../uniffi/build.sh) touches the tree. See ../README.md. +# Works on a scratch copy of the slice, so neither BoltFFI's output, browser.patch (a +# WebTransport client, so the slice reaches a relay), nor the UniFFI 0.31 pin (ubrn's +# version; see ../uniffi/build.sh) touches the tree. See ../generator.md. set -euo pipefail dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -19,6 +20,12 @@ ubrn_version=0.31.0-6 exit 1 } +# Skip the whole build when neither the slice, the overlay, nor this script changed. +stamp="$(cd "$slice" && find . -type f -not -path './target/*' | sort | xargs sha256sum | sha256sum) $(sha256sum <"$dir/browser.patch") $(sha256sum <"$0")" +if [[ -d "$dir/dist/boltffi" && -d "$dir/dist/uniffi" && "$(cat "$out/stamp" 2>/dev/null)" == "$stamp" ]]; then + exit 0 +fi + boltffi="$out/tools/bin/boltffi" [[ -x "$boltffi" ]] || cargo install --locked --quiet --root "$out/tools" "boltffi_cli@$boltffi_version" # BoltFFI transpiles its TypeScript with `tsc`, resolved from an ancestor of its output. @@ -32,6 +39,9 @@ ubrn="$target/test/wasm-uniffi/tools/bin/uniffi-bindgen-react-native" rm -rf "$out/src" "$dir/dist" mkdir -p "$out/src" "$dir/dist" cp -r "$slice/." "$out/src" +patch --quiet --directory "$out/src" --strip 1 <"$dir/browser.patch" +# The copy sits outside the repo, so it needs the repo's wasm32 cfgs (web-sys's WebTransport). +cp -r "$root/.cargo" "$out/src/.cargo" # BoltFFI: its CLI builds only `debug` or `release`, so release takes wasm-release's opt-level. sed -i "s|../../target/ffi-gen/boltffi|$out/boltffi|" "$out/src/boltffi.toml" @@ -47,7 +57,7 @@ sed -i 's|uniffi = { version = "0.32"|uniffi = { version = "=0.31"|' "$out/src/C sed -i "s|^web-async = .*|&\nuniffi-runtime-wasm = \"=$ubrn_version\"|" "$out/src/Cargo.toml" printf '\n#[cfg(all(feature = "uniffi", target_arch = "wasm32"))]\nextern crate uniffi_runtime_wasm as _;\n' >>"$out/src/src/lib.rs" cargo build --quiet --manifest-path "$out/src/Cargo.toml" --profile wasm-release --target wasm32-unknown-unknown \ - --no-default-features --features uniffi --target-dir "$out/cargo/uniffi" + --no-default-features --features uniffi,browser --target-dir "$out/cargo/uniffi" cat >"$out/package.json" </dev/null) +echo "$stamp" >"$out/stamp" diff --git a/test/wasm/slice/cases.ts b/test/wasm/slice/cases.ts new file mode 100644 index 00000000000..d60d9269392 --- /dev/null +++ b/test/wasm/slice/cases.ts @@ -0,0 +1,286 @@ +/** + * Parity cases for the slice, identical for each generator: the subset of + * `uniffi/cases.ts` the slice's surface can express. + * + * @module + */ +import type { Open, Session, Slice } from "./adapter.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 Report { + generator: string; + /** Whether the page allows `new Function`. */ + eval: boolean; + cases: CaseResult[]; +} + +/** Above 2^53, so a lossy `number` conversion anywhere changes it; the largest QUIC varint. */ +const HIGH = [2n ** 53n + 1n, 2n ** 62n - 1n]; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +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); + } +} + +/** 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; +} + +/** An error's name and message, plus any structured fields a generator attaches. */ +function describe(err: unknown): string { + if (!(err instanceof Error)) return String(err); + const fields = JSON.stringify(err, (_, value) => (typeof value === "bigint" ? String(value) : value)); + return `${err.name}: ${err.message}${fields && fields !== "{}" ? ` ${fields}` : ""}`.slice(0, 300); +} + +async function connect(slice: Slice, 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:"; + return slice.connect(url.toString(), (await response.text()).trim(), relay.version); +} + +const local: Record Promise> = { + "u64 above 2^53 round-trips locally": async (slice) => { + for (const sequence of HIGH) { + const group = slice.group(sequence); + if (group.sequence() !== sequence) throw new Error(`sequence ${group.sequence()} != ${sequence}`); + group.write(new Uint8Array(1), sequence); + const frame = await group.read(); + if (frame?.timestampUs !== sequence) throw new Error(`timestamp ${frame?.timestampUs} != ${sequence}`); + } + return undefined; + }, + + "AbortSignal rejects a pending read without stealing the next frame": async (slice) => { + const group = slice.group(); + const abort = new AbortController(); + const first = group.read(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 ?? new Error("rejected with nothing"), + ); + if (!error) throw new Error("aborted read resolved"); + group.write(new Uint8Array(3)); + const next = await within(group.read(), 1000, "read after abort"); + if (next?.payload.byteLength !== 3) throw new Error("the frame after an abort went missing"); + return `rejects with ${describe(error)}`; + }, + + "freeing a handle mid-call": async (slice) => { + // Reported, not asserted: moq-wasm rejects the call, UniFFI keeps it running. + const group = slice.group(); + const read = group.read(); + group.free(); + if (!(await pending(read))) { + const outcome = await read.then( + () => "resolves", + (err: unknown) => `rejects with ${describe(err)}`, + ); + return `free settles the pending read: ${outcome}`; + } + group.write(new Uint8Array(2)); + const outcome = await within( + read.then( + (frame) => `resolves later with ${frame?.payload.byteLength} bytes`, + (err: unknown) => `rejects later with ${describe(err)}`, + ), + 1000, + "read after free", + ); + return `the call keeps running: ${outcome}`; + }, + + "closing the producer ends a pending read": async (slice) => { + const group = slice.group(); + const read = group.read(); + group.close(); + const outcome = await within( + read.then( + (frame) => (frame ? "a frame" : "end of group"), + (err: unknown) => `rejects with ${describe(err)}`, + ), + 1000, + "read after close", + ); + if (outcome === "a frame") throw new Error("closed group yielded a frame"); + return outcome; + }, +}; + +const remote: Record Promise> = { + "negotiates the relay's version": async (slice, relay) => { + (await connect(slice, relay)).cancel(); + return undefined; + }, + + "u64 above 2^53 crosses the relay": async (slice, relay) => { + const publisher = await connect(slice, relay); + const subscriber = await connect(slice, relay); + try { + const path = `slice/u64/${crypto.randomUUID()}`; + const publish = publisher.publish(path); + const track = await (await within(subscriber.broadcast(path), 5000, "announce")).subscribe("data"); + const seen: string[] = []; + for (const sequence of [5n, 2n ** 32n, 2n ** 53n - 1n, ...HIGH]) { + const group = publish.group(sequence); + group.write(new Uint8Array(160), sequence); + group.finish(); + const next = within(track.recvGroup(), 5000, `recvGroup ${sequence} after [${seen}]`); + // Known: on moq-lite-06 the largest varint never arrives, as with moq-ffi. + if (sequence === HIGH[1] && relay.version === "moq-lite-06") { + const outcome = await next.then( + (got) => `arrived as ${got.sequence()}`, + (err: unknown) => describe(err), + ); + 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; + if (got.sequence() !== sequence) throw new Error(`sequence ${got.sequence()} != ${sequence}`); + const frame = await got.read(); + // 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(); + subscriber.cancel(); + } + }, + + "refuses a track the publisher does not serve": async (slice, relay) => { + const publisher = await connect(slice, relay); + const subscriber = await connect(slice, relay); + try { + const path = `slice/refuse/${crypto.randomUUID()}`; + publisher.publish(path); + const broadcast = await within(subscriber.broadcast(path), 5000, "announce"); + const outcome = await settle( + broadcast.subscribe("missing").then((track) => track.recvGroup()), + 5000, + "refused subscribe", + () => "a group", + (err: unknown) => `rejected: ${describe(err)}`, + ); + if (outcome === "a group") throw new Error("a missing track yielded a group"); + return outcome; + } finally { + publisher.cancel(); + subscriber.cancel(); + } + }, + + "session cancel rejects a pending remote read": async (slice, relay) => { + const publisher = await connect(slice, relay); + const subscriber = await connect(slice, relay); + try { + const path = `slice/close/${crypto.randomUUID()}`; + const publish = publisher.publish(path); + const track = await (await within(subscriber.broadcast(path), 5000, "announce")).subscribe("data"); + const group = publish.group(0n); + group.write(new Uint8Array(4), 0n); + const got = await within(track.recvGroup(), 5000, "recvGroup"); + await got.read(); + const read = got.read(); + if (!(await pending(read))) throw new Error("read settled early"); + subscriber.cancel(); + const outcome = await settle( + read, + 5000, + "read after session cancel", + (frame) => (frame ? "a frame" : "end of group"), + (err: unknown) => `rejected: ${describe(err)}`, + ); + if (outcome === "a frame") throw new Error("read yielded a frame after cancel"); + return outcome; + } finally { + publisher.cancel(); + } + }, +}; + +async function run(generator: string, relays: Relay[]): Promise { + const { open } = (await import(`/slice-${generator}.js`)) as { open: Open }; + const slice = await open(new URL(`/slice/${generator}.wasm`, location.href)); + const cases: CaseResult[] = []; + const record = async (name: string, body: () => Promise) => { + try { + cases.push({ name, ok: true, note: await within(body(), 15000, name) }); + } catch (err) { + cases.push({ name, ok: false, error: describe(err) }); + } + }; + for (const [name, body] of Object.entries(local)) await record(name, () => body(slice)); + for (const relay of relays) + for (const [name, body] of Object.entries(remote)) + await record(`${relay.name}: ${name}`, () => body(slice, relay)); + let evaluates = true; + try { + new Function(""); + } catch { + evaluates = false; + } + return { generator, eval: evaluates, cases }; +} + +declare global { + interface Window { + sliceCases: typeof run; + } +} +window.sliceCases = run; diff --git a/test/wasm/slice/driver.ts b/test/wasm/slice/driver.ts index f293c76d522..26acae605ae 100644 --- a/test/wasm/slice/driver.ts +++ b/test/wasm/slice/driver.ts @@ -1,13 +1,22 @@ /** - * Run the slice bench in headless Chromium, alternating generators across fresh pages so - * drift on a shared host hits both. Writes `slice.json` into the harness run directory. + * Run the slice's parity cases against every relay for each generator, normally and under a + * strict CSP, then its per-call bench, alternating generators across fresh pages so drift + * on a shared host hits both. Writes `slice.json` into the harness run directory and fails + * on any failed case. */ import { join } from "node:path"; +import { parseArgs } from "node:util"; import { chromium } from "playwright"; import type { Report } from "./bench.ts"; +import type { Report as CaseReport, Relay } from "./cases.ts"; +const { values } = parseArgs({ options: { relays: { type: "string" } } }); const run = process.env.MOQ_TEST_RUN; -if (!run) throw new Error("needs MOQ_TEST_RUN"); +if (!values.relays || !run) throw new Error("needs --relays and MOQ_TEST_RUN"); +const relays = (await Bun.file(values.relays).json()) as Relay[]; +const generators = ["uniffi", "boltffi"]; +// No 'unsafe-eval', as in uniffi/driver.ts. +const STRICT = "script-src 'self' 'wasm-unsafe-eval'"; const rounds = Number(process.env.MOQ_SLICE_ROUNDS ?? 6); const here = new URL(".", import.meta.url).pathname; const dist = join(here, "../dist"); @@ -20,10 +29,15 @@ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(req) { - const path = new URL(req.url).pathname; - if (path === "/") - return new Response(``, { - headers: { "content-type": "text/html", "cache-control": "no-store" }, + const url = new URL(req.url); + const path = url.pathname; + if (path === "/" || path === "/cases") + return new Response(``, { + headers: { + "content-type": "text/html", + "cache-control": "no-store", + ...(url.searchParams.has("strict") ? { "content-security-policy": STRICT } : {}), + }, }); const file = Bun.file(modules[path] ?? join(dist, path)); return (await file.exists()) @@ -35,10 +49,39 @@ const server = Bun.serve({ const browser = await chromium.launch({ channel: "chromium", headless: true }); const version = browser.version(); const reports: Report[] = []; +const parity: (CaseReport & { mode: string })[] = []; +let failed = 0; try { + for (const generator of generators) + for (const mode of ["jit", "strict"]) { + 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}/cases${mode === "strict" ? "?strict" : ""}`); + await page.waitForFunction(() => typeof window.sliceCases === "function"); + const report = await page.evaluate(([name, list]) => window.sliceCases(name, list), [ + generator, + relays, + ] as const); + if (errors.length) throw new Error(`${generator} ${mode}: ${errors.join("; ")}`); + if (report.eval !== (mode === "jit")) throw new Error(`${mode}: new Function allowed = ${report.eval}`); + parity.push({ ...report, mode }); + console.log(`── ${generator} ${mode}`); + 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}` : ""}`); + } + await context.close(); + } + for (let round = 0; round < rounds; round++) { // ABBA order: each generator goes first, and twice in a row, equally often. - const order = round % 2 ? ["boltffi", "uniffi"] : ["uniffi", "boltffi"]; + const order = round % 2 ? [...generators].reverse() : generators; for (const generator of order) { const context = await browser.newContext(); const page = await context.newPage(); @@ -56,7 +99,7 @@ try { } finally { await browser.close(); server.stop(true); - await Bun.write(join(run, "slice.json"), JSON.stringify({ browser: version, reports }, null, 2)); + await Bun.write(join(run, "slice.json"), JSON.stringify({ browser: version, parity, reports }, null, 2)); } // Median per case and generator, with the range, as the report tables show them. @@ -79,3 +122,7 @@ const cell = (values: number[] = []) => console.log("| case | UniFFI us | BoltFFI us |\n|---|---|---|"); for (const [name, byGenerator] of cases) console.log(`| ${name} | ${cell(byGenerator.get("uniffi"))} | ${cell(byGenerator.get("boltffi"))} |`); +if (failed) { + console.error(`${failed} slice case(s) failed`); + process.exit(1); +} diff --git a/test/wasm/slice/tsconfig.json b/test/wasm/slice/tsconfig.json index 26817e4097c..827426f5cb9 100644 --- a/test/wasm/slice/tsconfig.json +++ b/test/wasm/slice/tsconfig.json @@ -4,5 +4,5 @@ "types": ["bun"], "noEmit": true }, - "include": ["adapter.ts", "bench.ts", "boltffi.ts", "driver.ts", "uniffi.ts"] + "include": ["adapter.ts", "bench.ts", "boltffi.ts", "cases.ts", "driver.ts", "uniffi.ts"] } diff --git a/test/wasm/slice/uniffi.ts b/test/wasm/slice/uniffi.ts index 691e4082084..7a6d5cd79e5 100644 --- a/test/wasm/slice/uniffi.ts +++ b/test/wasm/slice/uniffi.ts @@ -1,25 +1,79 @@ -// The slice bound by UniFFI over ubrn's wasm2 player, behind the bench's adapter. -import type { Slice } from "./adapter.ts"; -import { MoqBroadcastProducer, noop, noopAsync, uniffiInitAsync, yieldOnce } from "./dist/uniffi/index.ts"; +// The slice bound by UniFFI over ubrn's wasm2 player, behind the harness adapter. +import type { Frame, Open, RemoteGroup } from "./adapter.ts"; +import { + MoqBroadcastProducer, + MoqBrowserSession, + type MoqFrame, + type MoqGroupConsumer, + type MoqGroupConsumerLike, + noop, + noopAsync, + uniffiInitAsync, + yieldOnce, +} from "./dist/uniffi/index.ts"; -export async function open(wasm: URL): Promise { +// The harness hands whole-buffer payloads, so taking `.buffer` adds no JS copy. +const lower = (bytes: Uint8Array, timestampUs?: bigint): MoqFrame => ({ + payload: bytes.buffer as ArrayBuffer, + timestampUs, +}); +const lift = (frame: MoqFrame | undefined): Frame | undefined => + frame && { payload: new Uint8Array(frame.payload), timestampUs: frame.timestampUs }; +const opts = (signal?: AbortSignal) => (signal ? { signal } : undefined); +const remote = (group: MoqGroupConsumerLike): RemoteGroup => ({ + sequence: () => group.sequence(), + read: async (signal) => lift(await group.readFrame(opts(signal))), +}); + +export const open: Open = async (wasm) => { await uniffiInitAsync(wasm); return { noop, noopAsync: () => noopAsync(), yieldOnce: () => yieldOnce(), - group() { + group(sequence) { const broadcast = new MoqBroadcastProducer(); const track = broadcast.publishTrack("data"); - const group = track.appendGroup(); + const group = sequence === undefined ? track.appendGroup() : track.createGroup(sequence); const consumer = group.consume(); return { sequence: () => consumer.sequence(), - // The bench's payloads span their whole buffer, so passing it adds no JS copy. - write: (bytes) => group.writeFrame({ payload: bytes.buffer as ArrayBuffer, timestampUs: 0n }), - read: async () => (await consumer.readFrame())?.payload.byteLength, - close: () => track.finish(), + write: (bytes, timestampUs = 0n) => group.writeFrame(lower(bytes, timestampUs)), + read: async (signal) => lift(await consumer.readFrame(opts(signal))), + // The generated `*Like` types omit uniffiDestroy; only the class declares it. + free: () => (consumer as MoqGroupConsumer).uniffiDestroy(), + close: () => { + group.finish(); + track.finish(); + }, + }; + }, + async connect(url, fingerprint, version) { + const session = await MoqBrowserSession.connect(url, fingerprint, version); + return { + async broadcast(path) { + const broadcast = await session.announcedBroadcast(path); + return { + async subscribe(name) { + const track = await broadcast.subscribeTrack(name); + return { recvGroup: async (signal) => remote(await track.recvGroup(opts(signal))) }; + }, + }; + }, + publish(path) { + const track = session.publishTrack(path, "data"); + return { + group(sequence) { + const group = track.createGroup(sequence); + return { + write: (bytes, timestampUs) => group.writeFrame(lower(bytes, timestampUs)), + finish: () => group.finish(), + }; + }, + }; + }, + cancel: () => session.cancel(), }; }, }; -} +}; diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts index e2666048cef..4ac1e21bb02 100644 --- a/test/wasm/uniffi/cases.ts +++ b/test/wasm/uniffi/cases.ts @@ -78,6 +78,17 @@ async function within(promise: Promise, ms: number, what: string): Promise } /** 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( @@ -133,7 +144,10 @@ const local: Record Promise> = { const first = consumer.readFrame({ signal: abort.signal }); if (!(await pending(first))) throw new Error("read settled with nothing written"); abort.abort(); - const error = await within(first, 1000, "aborted read").then( + const error = await settle( + first, + 1000, + "aborted read", () => undefined, (err: unknown) => err, ); @@ -182,12 +196,18 @@ const local: Record Promise> = { const read = consumer.readFrame(); if (!(await pending(read))) throw new Error("read settled with nothing written"); consumer.cancel(); - const first = await within(read, 1000, "cancelled read").then( + 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 within(consumer.readFrame(), 1000, "read after cancel").then( + const later = await settle( + consumer.readFrame(), + 1000, + "read after cancel", () => undefined, (err: unknown) => err as Error, ); @@ -262,11 +282,10 @@ const remote: Record Promise> = { broadcast.publishTrack("data", TIMED); broadcast.announce(MoqRoute.create({})); const consumer = await within(subscriber.consume().announcedBroadcast(path).available(), 5000, "announce"); - const outcome = await within( + const outcome = await settle( consumer.subscribeTrack("missing", undefined).then((track) => track.recvGroup()), 5000, "refused subscribe", - ).then( (group) => (group ? "a group" : "no group"), (err: unknown) => `rejected: ${(err as Error).message}`, ); @@ -296,7 +315,10 @@ const remote: Record Promise> = { const read = got.readFrame(); if (!(await pending(read))) throw new Error("read settled early"); subscriber.cancel(1); - const outcome = await within(read, 5000, "read after session cancel").then( + const outcome = await settle( + read, + 5000, + "read after session cancel", (frame) => (frame ? "a frame" : "end of group"), (err: unknown) => `rejected: ${(err as Error).message}`, ); From 46a0586fde2d4975822b6a76d95f5112d1d994c3 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:36:42 -0700 Subject: [PATCH 08/10] test(wasm): keep the UniFFI harness the maintainer chose, plan its follow-ups The browser binds moq-ffi through UniFFI over ubrn. Drop the BoltFFI slice harness (its numbers stay in the report, pointing at the commit that ran them), drop pin.patch's clock workaround now that moq-mux reads moq-clock's browser wall clock, and replace the generator quest with its follow-ups. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/lite06-max-sequence.md | 23 ++ quest/m2/ffi-bytes.md | 4 + quest/m2/rs2ts/README.md | 10 +- quest/m2/rs2ts/generator.md | 103 -------- quest/m2/rs2ts/net-only.md | 30 +++ quest/m2/rs2ts/ubrn-032.md | 30 +++ quest/m2/rs2ts/wasm-adopt.md | 5 + quest/m2/rs2ts/wasm-bindings.md | 18 +- test/tsconfig.json | 2 +- test/wasm/README.md | 15 +- test/wasm/baseline/consumer.ts | 36 --- test/wasm/baseline/driver.ts | 35 +-- test/wasm/baseline/fixture.ts | 2 +- test/wasm/baseline/uniffi.ts | 2 +- test/wasm/generator.md | 45 ++-- test/wasm/run.sh | 25 -- test/wasm/slice/adapter.ts | 54 ---- test/wasm/slice/bench.ts | 106 -------- test/wasm/slice/boltffi.ts | 73 ------ test/wasm/slice/browser.patch | 431 -------------------------------- test/wasm/slice/build.sh | 75 ------ test/wasm/slice/cases.ts | 286 --------------------- test/wasm/slice/driver.ts | 128 ---------- test/wasm/slice/tsconfig.json | 8 - test/wasm/slice/uniffi.ts | 79 ------ test/wasm/uniffi/pin.patch | 25 -- 27 files changed, 151 insertions(+), 1500 deletions(-) create mode 100644 quest/m1/lite06-max-sequence.md delete mode 100644 quest/m2/rs2ts/generator.md create mode 100644 quest/m2/rs2ts/net-only.md create mode 100644 quest/m2/rs2ts/ubrn-032.md delete mode 100644 test/wasm/slice/adapter.ts delete mode 100644 test/wasm/slice/bench.ts delete mode 100644 test/wasm/slice/boltffi.ts delete mode 100644 test/wasm/slice/browser.patch delete mode 100755 test/wasm/slice/build.sh delete mode 100644 test/wasm/slice/cases.ts delete mode 100644 test/wasm/slice/driver.ts delete mode 100644 test/wasm/slice/tsconfig.json delete mode 100644 test/wasm/slice/uniffi.ts diff --git a/quest/m1/README.md b/quest/m1/README.md index b66b5446a41..666ef59f5d6 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 00000000000..af14b71b8f2 --- /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 2d1e205c669..0a7e7c4a3d0 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 9897427f7f9..2dc132db84a 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 d0834584b9d..00000000000 --- 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 00000000000..4a8ea277acb --- /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 00000000000..a2c5f891de4 --- /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 d8736adb5e3..8d747fd2699 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 7c4a0b0325f..e4a0e779053 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 0c64e606fec..5bd3f1db124 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", "wasm/uniffi", "wasm/slice"] + "exclude": ["**/node_modules", "**/dist", "**/target", "wasm/uniffi"] } diff --git a/test/wasm/README.md b/test/wasm/README.md index c6e2aa5e81d..d072cee881f 100644 --- a/test/wasm/README.md +++ b/test/wasm/README.md @@ -176,14 +176,11 @@ API parity remain explicit gaps too. ## Generated bindings ```bash -just test wasm-baseline --uniffi --slice -just test wasm --uniffi --slice +just test wasm-baseline --uniffi ``` -`--uniffi` adds moq-ffi, bound by UniFFI over ubrn's `wasm2` player, as a baseline -backend. It also runs parity cases and per-call timings in `uniffi/` against every relay, -once normally and once under a strict CSP. `--slice` does the same for the -`bench/ffi-gen` slice (from #5736) bound by UniFFI and by BoltFFI, with -`slice/browser.patch` adding the WebTransport client the slice lacks. Both build from -scratch copies, since ubrn needs a UniFFI pin that does not land. -[generator.md](generator.md) is the resulting report. +`--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 ac6e245651c..0ff9b378e3d 100644 --- a/test/wasm/baseline/consumer.ts +++ b/test/wasm/baseline/consumer.ts @@ -1,7 +1,6 @@ import { Milli } from "@moq/clock"; import type * as Moq from "@moq/net"; import type * as Wasm from "@moq/wasm"; -import type { Open } from "../slice/adapter.ts"; import { type Sample, verify } from "./fixture.ts"; import type { Uniffi } from "./uniffi.ts"; @@ -145,41 +144,6 @@ async function run(config: Sample) { }), ); }; - } else if (config.backend === "slice-uniffi" || config.backend === "slice-boltffi") { - if (config.transport !== "webtransport") - throw new Error("the slice's browser client has no WebSocket fallback"); - const generator = config.backend.slice("slice-".length); - const memoryOf = captureMemory(); - const { open } = (await import(config.module)) as { open: Open }; - const slice = await open(new URL(`slice/${generator}.wasm`, config.module)); - memory = memoryOf(); - startup = performance.now() - start; - const { url, hex } = await pin(config.url); - read = async () => { - const session = await slice.connect(url.toString(), hex, config.version); - disposers.push(() => session.cancel()); - await Promise.all( - Array.from({ length: config.publishers }, async (_, publisher) => { - const broadcast = await session.broadcast(`${config.id}/${publisher}`); - const track = await broadcast.subscribe("data"); - const group = await track.recvGroup(); - if (group.sequence() !== BigInt(config.sequence)) - throw new Error("incorrect slice group sequence"); - for (let frame = 0; frame < config.frames; frame++) { - const got = await group.read(); - if ( - !got || - (config.version === "moq-lite-02" - ? got.timestampUs !== undefined - : got.timestampUs !== BigInt(frame * 1000)) - ) - throw new Error(`incorrect slice frame timestamp ${frame}`); - check(got.payload, frame); - } - if (await group.read()) throw new Error("unexpected extra slice frame"); - }), - ); - }; } else { if (config.transport !== "webtransport") throw new Error("moq-ffi's wasm client has no WebSocket fallback"); const memoryOf = captureMemory(); diff --git a/test/wasm/baseline/driver.ts b/test/wasm/baseline/driver.ts index 1ccb0e5bf07..c6815ea4930 100644 --- a/test/wasm/baseline/driver.ts +++ b/test/wasm/baseline/driver.ts @@ -22,12 +22,6 @@ 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"); -// The bench/ffi-gen slice with browser.patch, under each generator; built by slice/build.sh. -const slice = process.env.MOQ_WASM_SLICE === "1"; -const sliceModules: Record = { - "/slice/uniffi.wasm": join(root, "test/wasm/slice/dist/uniffi/moq_ffi_gen.wasm"), - "/slice/boltffi.wasm": join(root, "test/wasm/slice/dist/boltffi/moq_ffi_gen_bg.wasm"), -}; const relays = (await Bun.file(values.relays).json()) as RelayFixture[]; if (!relays.length) throw new Error("no relay fixtures"); const server = Bun.serve({ @@ -43,7 +37,7 @@ const server = Bun.serve({ ? Bun.file(join(wasm, path.slice(6))) : path.startsWith("/uniffi/") ? Bun.file(join(generated, path.slice(8))) - : Bun.file(sliceModules[path] ?? join(dist, path)); + : Bun.file(join(dist, path)); return (await file.exists()) ? new Response(file, { headers: { "cache-control": "no-store" } }) : new Response("not found", { status: 404 }); @@ -84,9 +78,6 @@ const report = { 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", - slice: slice - ? "bench/ffi-gen at 09ff0b861 plus slice/browser.patch; UniFFI 0.31 over ubrn wasm2 and BoltFFI 0.31.1; wasm-release; bundled with bun --minify" - : "not run", }, tools: { rust: command("rustc", "--version"), @@ -107,13 +98,6 @@ const report = { 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"))] : [], - // Each adapter bundle holds its generated bindings and runtime; the adapter is a few hundred bytes. - "slice-uniffi": slice - ? [await asset(join(dist, "slice-uniffi.js")), await asset(sliceModules["/slice/uniffi.wasm"])] - : [], - "slice-boltffi": slice - ? [await asset(join(dist, "slice-boltffi.js")), await asset(sliceModules["/slice/boltffi.wasm"])] - : [], harness: [await asset(join(dist, "consumer.js")), await asset(join(dist, "publisher.js"))], }, limits: [ @@ -134,12 +118,7 @@ const browser = await chromium.launch({ channel: "chromium", headless: true }); report.tools.browser = browser.version(); // 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) : []), - ...(slice ? (["slice-uniffi", "slice-boltffi"] as const) : []), -]; +const backendsEnabled: Sample["backend"][] = ["js", "wasm", ...(uniffi ? (["uniffi"] as const) : [])]; const order: Sample["backend"][] = backendsEnabled.length === 2 ? ["js", "js", "wasm", "wasm", "js", "wasm"] @@ -181,15 +160,7 @@ try { version: relay.version, backend, transport: "webtransport", - module: `${endpoint}/${ - { - js: "net.js", - wasm: "wasm/moq.js", - uniffi: "uniffi.js", - "slice-uniffi": "slice-uniffi.js", - "slice-boltffi": "slice-boltffi.js", - }[backend] - }`, + 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 2c1a1014178..c3763a7d4d3 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" | "uniffi" | "slice-uniffi" | "slice-boltffi"; + 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 index 4b46b00ab52..8cc3dc01a10 100644 --- a/test/wasm/baseline/uniffi.ts +++ b/test/wasm/baseline/uniffi.ts @@ -1,5 +1,5 @@ /** - * The slice of the ubrn-generated moq-ffi bindings the baseline consumer reads through. + * 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. diff --git a/test/wasm/generator.md b/test/wasm/generator.md index f0cdbc2b3fa..49b7523d757 100644 --- a/test/wasm/generator.md +++ b/test/wasm/generator.md @@ -2,34 +2,37 @@ 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). It adopts -nothing. +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: open, see the PR that added this file. +**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 --slice # every backend in the baseline sweep, plus parity cases and per-call timings -just test wasm --uniffi --slice # the parity cases and per-call timings alone +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. The patch also carries a -browser fix moq-ffi needs (see [Bugs found](#bugs-found)). moq-ffi built on 0.31 with -no other source change. +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. -[`slice/build.sh`](slice/build.sh) copies `bench/ffi-gen` (from -[#5736](https://github.com/moq-dev/moq/pull/5736)), applies -[`slice/browser.patch`](slice/browser.patch), and binds it with both generators. The -patch adds a `browser` feature: a WebTransport session that consumes and publishes, like -moq-ffi's wasm client, plus `create_group` for high sequences. +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 @@ -166,8 +169,8 @@ the strict CSP below): ## Parity -[`uniffi/cases.ts`](uniffi/cases.ts) and [`slice/cases.ts`](slice/cases.ts) run against -all three relays, normally and under a strict CSP. Every case passes in both modes for all +[`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 | @@ -241,10 +244,12 @@ targets, over one dual-annotated crate. ## Bugs found -- **moq-ffi panics creating any broadcast in the browser.** `moq_mux`'s catalog clock - calls `std::time::SystemTime::now`, which wasm32-unknown-unknown does not implement. - The pin patch leaves the wall clock unset in the browser, as moq-net already does for - its own; the real fix belongs to the bindings parity work. +- **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. + 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/run.sh b/test/wasm/run.sh index 348f7bc9805..b74d06ac13b 100755 --- a/test/wasm/run.sh +++ b/test/wasm/run.sh @@ -36,7 +36,6 @@ PORT="${WASM_PORT:-}" # fixture wants; the workload is three connections and a few hundred KiB. BASELINE= UNIFFI= -SLICE= PROFILE="${WASM_PROFILE:-debug}" RELAY="${RELAY_BIN:-}" @@ -52,12 +51,6 @@ while [[ $# -gt 0 ]]; do UNIFFI=true shift ;; - --slice) - # bench/ffi-gen's slice bound by UniFFI and BoltFFI: parity cases, per-call - # costs, and (with --baseline) two more baseline backends. - SLICE=true - shift - ;; --timeout) if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then echo "error: --timeout requires a value" >&2 @@ -118,11 +111,6 @@ if [[ -n $UNIFFI ]]; then "$WASM_DIR/uniffi/build.sh" fi -if [[ -n $SLICE ]]; then - echo "building the moq-ffi slice with BoltFFI and UniFFI..." - "$WASM_DIR/slice/build.sh" -fi - echo "building moq-relay ($PROFILE)..." flag=() [[ "$PROFILE" == "debug" ]] || flag=(--profile "$PROFILE") @@ -143,7 +131,6 @@ 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 -[[ -z $SLICE ]] || bunx tsc --noEmit -p slice/tsconfig.json echo "bundling the harness page..." rm -rf dist @@ -157,19 +144,11 @@ if [[ -n $BASELINE ]]; then bun build uniffi/dist/index.ts --outfile dist/uniffi.js --target browser --minify >/dev/null export MOQ_WASM_UNIFFI=1 fi - [[ -z $SLICE ]] || export MOQ_WASM_SLICE=1 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 -if [[ -n $SLICE ]]; then - bun build slice/cases.ts --outfile dist/slice-cases.js --target browser >/dev/null - bun build slice/bench.ts --outfile dist/slice-bench.js --target browser >/dev/null - for generator in uniffi boltffi; do - bun build "slice/$generator.ts" --outfile "dist/slice-$generator.js" --target browser --minify >/dev/null - done -fi # ── relays ────────────────────────────────────────────────────────────────── # One reservation per flavour rather than a span from a fixed base: a span means @@ -224,10 +203,6 @@ 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 -eq 0 && -n $SLICE ]]; then - harness_spawn slice - bun slice/driver.ts --relays "$HARNESS_RUN/relays.json" - harness_wait "$HARNESS_PID" || status=$? -fi if [[ $status -ne 0 ]]; then for flavour in "${FLAVOURS[@]}"; do diff --git a/test/wasm/slice/adapter.ts b/test/wasm/slice/adapter.ts deleted file mode 100644 index 2d97551d114..00000000000 --- a/test/wasm/slice/adapter.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** What the slice harness needs from either generator, so one page drives both. */ -export interface Slice { - noop(): void; - noopAsync(): Promise; - yieldOnce(): Promise; - /** A fresh standalone group at `sequence` (default: the next), and a consumer of it. */ - group(sequence?: bigint): Group; - /** Dial `url` trusting only the hex SHA-256 `fingerprint`, offering only `version`. */ - connect(url: string, fingerprint: string, version: string): Promise; -} - -/** A frame as both generators hand it back, payload copied out of Rust. */ -export interface Frame { - payload: Uint8Array; - timestampUs?: bigint; -} - -export interface Group { - sequence(): bigint; - write(bytes: Uint8Array, timestampUs?: bigint): void; - read(signal?: AbortSignal): Promise; - /** Free the consumer's handle, leaving any pending call to the generator. */ - free(): void; - close(): void; -} - -export interface Session { - /** Wait until `path` is announced. */ - broadcast(path: string): Promise; - /** Announce a broadcast at `path` with one microsecond-timed track, created first. */ - publish(path: string): Publisher; - cancel(): void; -} - -export interface Broadcast { - subscribe(name: string): Promise; -} - -export interface Track { - /** The next group; rejects once the track ends. */ - recvGroup(signal?: AbortSignal): Promise; -} - -export interface RemoteGroup { - sequence(): bigint; - read(signal?: AbortSignal): Promise; -} - -export interface Publisher { - group(sequence: bigint): { write(bytes: Uint8Array, timestampUs: bigint): void; finish(): void }; -} - -/** Each adapter bundle's one export. */ -export type Open = (wasm: URL) => Promise; diff --git a/test/wasm/slice/bench.ts b/test/wasm/slice/bench.ts deleted file mode 100644 index b8ba3c504ad..00000000000 --- a/test/wasm/slice/bench.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Per-call costs of the moq-ffi slice in the browser, identical for each generator. - * - * `driver.ts` opens a fresh page per round and passes which generator's adapter to load. - * - * @module - */ -import type { Open } from "./adapter.ts"; - -export interface Bench { - name: string; - iterations: number; - us_per_op: number; -} - -export interface Report { - generator: string; - init_ms: number; - bench: Bench[]; -} - -// Each batch is a fresh group, under moq-net's 8192-frame and 32 MiB group caps. -const BATCH = 1000; - -async function run(generator: string): Promise { - const start = performance.now(); - // Runtime URLs keep each generator's bundle out of the other's page. - const { open } = (await import(`/slice-${generator}.js`)) as { open: Open }; - const slice = await open(new URL(`/slice/${generator}.wasm`, location.href)); - const init_ms = performance.now() - start; - - const bench: Bench[] = []; - const measure = async (name: string, batches: number, per: number, body: () => Promise) => { - await body(); // warm the dispatchers - let elapsed = 0; - for (let i = 0; i < batches; i++) elapsed += await body(); - bench.push({ name, iterations: batches * per, us_per_op: (elapsed * 1000) / (batches * per) }); - }; - const timed = async (body: () => unknown | Promise) => { - const start = performance.now(); - for (let i = 0; i < BATCH; i++) await body(); - return performance.now() - start; - }; - - await measure("sync noop()", 10, BATCH, async () => { - const start = performance.now(); - for (let i = 0; i < BATCH; i++) slice.noop(); - return performance.now() - start; - }); - await measure("sync getter: sequence()", 10, BATCH, async () => { - const group = slice.group(); - const start = performance.now(); - for (let i = 0; i < BATCH; i++) group.sequence(); - const elapsed = performance.now() - start; - group.close(); - return elapsed; - }); - await measure("async noop_async()", 5, BATCH, () => timed(() => slice.noopAsync())); - await measure("async yield_once(): one wakeup", 5, BATCH, () => timed(() => slice.yieldOnce())); - - for (const size of [160, 4096, 131072]) { - const bytes = new Uint8Array(size).map((_, i) => i % 251); - const per = Math.min(BATCH, (16 << 20) / size); - const batches = size > 4096 ? 8 : 10; - await measure(`sync write_frame ${size}B`, batches, per, async () => { - const group = slice.group(); - const start = performance.now(); - for (let i = 0; i < per; i++) group.write(bytes); - const elapsed = performance.now() - start; - group.close(); - return elapsed; - }); - await measure(`async read_frame, buffered ${size}B`, batches, per, async () => { - const group = slice.group(); - for (let i = 0; i < per; i++) group.write(bytes); - const start = performance.now(); - for (let i = 0; i < per; i++) - if ((await group.read())?.payload.byteLength !== size) throw new Error("wrong frame size"); - const elapsed = performance.now() - start; - group.close(); - return elapsed; - }); - } - - const bytes = new Uint8Array(160); - await measure("async read_frame, parked then woken 160B", 5, BATCH, async () => { - const group = slice.group(); - const start = performance.now(); - for (let i = 0; i < BATCH; i++) { - const read = group.read(); - group.write(bytes); - await read; - } - const elapsed = performance.now() - start; - group.close(); - return elapsed; - }); - return { generator, init_ms, bench }; -} - -declare global { - interface Window { - sliceBench: typeof run; - } -} -window.sliceBench = run; diff --git a/test/wasm/slice/boltffi.ts b/test/wasm/slice/boltffi.ts deleted file mode 100644 index 15ac14a08e6..00000000000 --- a/test/wasm/slice/boltffi.ts +++ /dev/null @@ -1,73 +0,0 @@ -// The slice bound by BoltFFI, behind the harness adapter. -import type { Frame, Open, RemoteGroup } from "./adapter.ts"; -import init, { - MoqBroadcastProducer, - MoqBrowserSession, - type MoqFrame, - type MoqGroupConsumer, - noop, - noopAsync, - yieldOnce, -} from "./dist/boltffi/moq_ffi_gen.js"; - -const lift = (frame: MoqFrame | null): Frame | undefined => - frame ? { payload: frame.payload, timestampUs: frame.timestampUs ?? undefined } : undefined; -const opts = (signal?: AbortSignal) => (signal ? { signal } : undefined); -const remote = (group: MoqGroupConsumer): RemoteGroup => ({ - sequence: () => group.sequence(), - read: async (signal) => lift(await group.readFrame(opts(signal))), -}); - -export const open: Open = async (wasm) => { - await init(await fetch(wasm)); - return { - noop, - noopAsync: () => noopAsync(), - yieldOnce: () => yieldOnce(), - group(sequence) { - const broadcast = MoqBroadcastProducer.new(); - const track = broadcast.publishTrack("data"); - const group = sequence === undefined ? track.appendGroup() : track.createGroup(sequence); - const consumer = group.consume(); - return { - sequence: () => consumer.sequence(), - write: (bytes, timestampUs = 0n) => group.writeFrame({ payload: bytes, timestampUs }), - read: async (signal) => lift(await consumer.readFrame(opts(signal))), - free: () => consumer.dispose(), - close: () => { - group.finish(); - track.finish(); - }, - }; - }, - async connect(url, fingerprint, version) { - // BoltFFI types an async constructor's result as nullable (boltffi#95). - const session = await MoqBrowserSession.connect(url, fingerprint, version); - if (!session) throw new Error("BoltFFI's async constructor returned null"); - return { - async broadcast(path) { - const broadcast = await session.announcedBroadcast(path); - return { - async subscribe(name) { - const track = await broadcast.subscribeTrack(name); - return { recvGroup: async (signal) => remote(await track.recvGroup(opts(signal))) }; - }, - }; - }, - publish(path) { - const track = session.publishTrack(path, "data"); - return { - group(sequence) { - const group = track.createGroup(sequence); - return { - write: (bytes, timestampUs) => group.writeFrame({ payload: bytes, timestampUs }), - finish: () => group.finish(), - }; - }, - }; - }, - cancel: () => session.cancel(), - }; - }, - }; -}; diff --git a/test/wasm/slice/browser.patch b/test/wasm/slice/browser.patch deleted file mode 100644 index ab87a5285d7..00000000000 --- a/test/wasm/slice/browser.patch +++ /dev/null @@ -1,431 +0,0 @@ -diff --git a/boltffi.toml b/boltffi.toml ---- a/boltffi.toml -+++ b/boltffi.toml -@@ -30,8 +30,8 @@ - output = "../../target/ffi-gen/boltffi/wasm" - # Only `debug` or `release`: moq-wasm's size-tuned `wasm-release` is not selectable. - profile = "release" --# No `client`: the browser dials through web-transport-wasm, outside the slice. --cargo_args = ["--no-default-features"] -+# No `client`: the browser dials through `browser` instead. -+cargo_args = ["--no-default-features", "--features=browser"] - - # moq-wasm ships without wasm-opt, and the dev shell has none. - [targets.wasm.optimize] -diff --git a/Cargo.toml b/Cargo.toml ---- a/Cargo.toml -+++ b/Cargo.toml -@@ -19,10 +19,12 @@ - # Pick exactly one generator per build. - uniffi = ["dep:uniffi", "dep:moq-ffi-gen-uniffi-shim"] - boltffi = ["dep:boltffi"] --# `MoqClient` and `MoqSession`, native only: the browser dials through --# web-transport-wasm, which the slice leaves out. A feature rather than a target `cfg`, -+# `MoqClient` and `MoqSession`, native only: the browser dials through `browser`, over -+# web-transport-wasm. A feature rather than a target `cfg`, - # because BoltFFI's macros never see the target, so `cfg(target_arch)` reads as unset. - client = ["dep:moq-tokio", "dep:url"] -+# `MoqBrowserSession`, wasm only: dials through web-transport-wasm like moq-ffi's wasm client. -+browser = ["dep:bytes", "dep:url", "dep:web-transport-trait", "dep:web-transport-wasm"] - - [dependencies] - boltffi = { version = "=0.31.1", optional = true } -@@ -44,6 +46,10 @@ - # As moq-ffi: drops the `Send` bounds moq-net's wasm futures and handles cannot meet. - uniffi = { version = "0.32", optional = true, features = ["wasm-unstable-single-threaded"] } - web-async = "0.1.5" -+bytes = { version = "1", optional = true } -+url = { version = "2", optional = true } -+web-transport-trait = { version = "0.5", optional = true } -+web-transport-wasm = { version = "0.8", optional = true } - - [dev-dependencies] - libc = "0.2" -diff --git a/src/browser.rs b/src/browser.rs ---- a/src/browser.rs -+++ b/src/browser.rs -@@ -0,0 +1,159 @@ -+//! Connecting to a relay from the browser, like moq-ffi's wasm client: WebTransport through -+//! web-transport-wasm, pinned to a certificate hash, offering one version. -+//! -+//! A `browser` feature rather than `cfg(target_arch)`, because BoltFFI's scanner ignores -+//! target cfgs (see `client`). -+ -+#[cfg(feature = "boltffi")] -+use boltffi::export; -+#[cfg(feature = "uniffi")] -+use moq_ffi_gen_uniffi_shim::export; -+ -+use std::sync::Mutex; -+ -+use crate::{MoqBroadcastProducer, MoqError, MoqTrackConsumer, MoqTrackProducer, spawn}; -+ -+#[cfg(target_arch = "wasm32")] -+type Transport = crate::browser_transport::Session; -+ -+fn net(err: impl std::fmt::Display) -> MoqError { -+ MoqError::Net { -+ reason: err.to_string(), -+ } -+} -+ -+/// A browser session: publishes and consumes through two origins of its own. -+#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] -+pub struct MoqBrowserSession { -+ session: Mutex>, -+ publish: moq_net::origin::Producer, -+ broadcasts: Mutex>, -+ consume: moq_net::origin::Consumer, -+} -+ -+#[export] -+impl MoqBrowserSession { -+ /// Dial `url`, trusting only the hex SHA-256 `fingerprint` and offering only `version`. -+ #[cfg_attr(feature = "uniffi", uniffi::constructor)] -+ pub async fn connect(url: String, fingerprint: String, version: String) -> Result { -+ SingleThreaded(Self::dial(url, fingerprint, version)).await -+ } -+ -+ /// Wait until `path` is announced, then return it. -+ pub async fn announced_broadcast(&self, path: String) -> Result { -+ let broadcast = SingleThreaded(self.consume.routed_broadcast(path.as_str())) -+ .await -+ .map_err(net)?; -+ Ok(MoqBroadcastConsumer { inner: broadcast }) -+ } -+ -+ /// Publish a microsecond-timed track `name` in a new broadcast at `path`, then announce it. -+ /// -+ /// Announcing after the track exists, as moq-ffi documents, so a subscriber that races -+ /// the announcement never finds the broadcast without its track. -+ pub fn publish_track(&self, path: String, name: String) -> Result { -+ let broadcast = MoqBroadcastProducer::from_inner(self.publish.create_broadcast(path.as_str())?); -+ let track = broadcast.publish_track(name)?; -+ broadcast.inner.announce(moq_net::origin::Route::default())?; -+ // The session owns the broadcast: dropping its producer would end it. -+ self.broadcasts.lock().unwrap().push(broadcast); -+ Ok(track) -+ } -+ -+ /// Close the session; pending calls on it fail. -+ pub fn cancel(&self) { -+ if let Some(session) = self.session.lock().unwrap().take() { -+ session.abort(moq_net::Error::Cancel); -+ } -+ } -+} -+ -+impl MoqBrowserSession { -+ // BoltFFI reads binding metadata from a host build, where none of this exists. -+ #[cfg(not(target_arch = "wasm32"))] -+ async fn dial(_url: String, _fingerprint: String, _version: String) -> Result { -+ Err(net("the browser client runs only on wasm32")) -+ } -+ -+ #[cfg(target_arch = "wasm32")] -+ async fn dial(url: String, fingerprint: String, version: String) -> Result { -+ let url = url::Url::parse(&url).map_err(net)?; -+ let version: moq_net::Version = version.parse().map_err(net)?; -+ let versions = moq_net::Versions::from(vec![version]); -+ let hash = (0..fingerprint.len()) -+ .step_by(2) -+ .map(|i| u8::from_str_radix(fingerprint.get(i..i + 2).unwrap_or("zz"), 16)) -+ .collect::, _>>() -+ .map_err(net)?; -+ -+ let transport = web_transport_wasm::ClientBuilder::new() -+ .with_protocols(versions.alpns()) -+ .with_server_certificate_hashes(vec![hash]) -+ .connect(url) -+ .await -+ .map(Transport::new) -+ .map_err(net)?; -+ -+ let (publish, publish_driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); -+ let (subscribe, subscribe_driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); -+ spawn(async move { -+ moq_net::time::run(publish_driver).await; -+ }); -+ spawn(async move { -+ moq_net::time::run(subscribe_driver).await; -+ }); -+ let consume = subscribe.consume(); -+ -+ let (session, driver) = moq_net::Client::new() -+ .with_versions(versions) -+ .with_publisher(&publish.consume()) -+ .with_subscriber(subscribe) -+ .connect(web_async::time::Instant::now(), transport) -+ .await -+ .map_err(net)?; -+ spawn(async move { -+ moq_net::time::run(driver).await; -+ }); -+ -+ Ok(Self { -+ session: Mutex::new(Some(session)), -+ publish, -+ broadcasts: Mutex::default(), -+ consume, -+ }) -+ } -+} -+ -+/// Asserts a future is `Send`. BoltFFI requires `Send` futures on every target, but the -+/// browser's WebTransport handles are `Rc`s; wasm32 without threads never moves them. -+/// UniFFI's `wasm-unstable-single-threaded` drops the same bound. -+struct SingleThreaded(F); -+ -+// SAFETY: wasm32-unknown-unknown without the atomics feature has one thread. -+unsafe impl Send for SingleThreaded {} -+ -+impl std::future::Future for SingleThreaded { -+ type Output = F::Output; -+ -+ fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll { -+ // SAFETY: structural pinning; the field is never moved out. -+ unsafe { self.map_unchecked_mut(|this| &mut this.0) }.poll(cx) -+ } -+} -+ -+/// A broadcast announced by the relay. -+#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] -+pub struct MoqBroadcastConsumer { -+ inner: moq_net::broadcast::Consumer, -+} -+ -+#[export] -+impl MoqBroadcastConsumer { -+ /// Subscribe to the track `name`. -+ pub async fn subscribe_track(&self, name: String) -> Result { -+ let track = SingleThreaded(self.inner.track(&name)?.subscribe(None)).await?; -+ Ok(MoqTrackConsumer { -+ inner: Mutex::new(track), -+ }) -+ } -+} -diff --git a/src/browser_transport.rs b/src/browser_transport.rs ---- a/src/browser_transport.rs -+++ b/src/browser_transport.rs -@@ -0,0 +1,166 @@ -+//! moq-ffi's WebTransport adapter at the slice's moq-net revision, minus its tests. -+//! thiserror is swapped for hand-written impls: BoltFFI reads an item-level `#[error]` -+//! as its own error marker. -+ -+use bytes::{Buf, BufMut, Bytes}; -+use moq_net::transport::{self as moq, poll}; -+use std::task::{Context, Poll}; -+use web_transport_trait::{self as wt, poll as backend}; -+ -+/// A backend failure preserving the session and stream code registries. -+#[derive(Debug)] -+pub struct Error(E); -+ -+impl std::fmt::Display for Error { -+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -+ self.0.fmt(f) -+ } -+} -+ -+impl std::error::Error for Error { -+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { -+ self.0.source() -+ } -+} -+ -+impl moq::Error for Error { -+ fn session_error(&self) -> Option<(u32, String)> { -+ self.0.session_error() -+ } -+ fn stream_error(&self) -> Option { -+ self.0.stream_error() -+ } -+} -+ -+struct Stats(S); -+impl moq::Stats for Stats { -+ fn bytes_sent(&self) -> Option { -+ self.0.bytes_sent() -+ } -+ fn bytes_received(&self) -> Option { -+ self.0.bytes_received() -+ } -+ fn bytes_lost(&self) -> Option { -+ self.0.bytes_lost() -+ } -+ fn packets_sent(&self) -> Option { -+ self.0.packets_sent() -+ } -+ fn packets_received(&self) -> Option { -+ self.0.packets_received() -+ } -+ fn packets_lost(&self) -> Option { -+ self.0.packets_lost() -+ } -+ fn rtt(&self) -> Option { -+ self.0.rtt() -+ } -+ fn estimated_send_rate(&self) -> Option { -+ self.0.estimated_send_rate() -+ } -+} -+/// A WebTransport session adapted to moq-net. -+#[derive(Clone)] -+pub struct Session(S); -+impl Session { -+ /// Wrap a backend poll session. -+ pub fn new(session: S) -> Self { -+ Self(session) -+ } -+} -+impl + Clone + 'static> poll::Session for Session { -+ type SendStream = SendStream; -+ type RecvStream = RecvStream; -+ type Error = Error; -+ fn poll_accept_uni(&mut self, cx: &mut Context<'_>) -> Poll> { -+ backend::Session::poll_accept_uni(&mut self.0, cx) -+ .map_err(Error) -+ .map(|res| res.map(RecvStream)) -+ } -+ fn poll_accept_bi(&mut self, cx: &mut Context<'_>) -> Poll, Self::Error>> { -+ backend::Session::poll_accept_bi(&mut self.0, cx) -+ .map_err(Error) -+ .map(|res| res.map(|(send, recv)| (SendStream(send), RecvStream(recv)))) -+ } -+ fn poll_open_uni(&mut self, cx: &mut Context<'_>) -> Poll> { -+ backend::Session::poll_open_uni(&mut self.0, cx) -+ .map_err(Error) -+ .map(|res| res.map(SendStream)) -+ } -+ fn poll_open_bi(&mut self, cx: &mut Context<'_>) -> Poll, Self::Error>> { -+ backend::Session::poll_open_bi(&mut self.0, cx) -+ .map_err(Error) -+ .map(|res| res.map(|(send, recv)| (SendStream(send), RecvStream(recv)))) -+ } -+ fn poll_send_datagram(&mut self, cx: &mut Context<'_>, payload: &[u8]) -> Poll> { -+ backend::Session::poll_send_datagram(&mut self.0, cx, payload).map_err(Error) -+ } -+ fn poll_recv_datagram(&mut self, cx: &mut Context<'_>) -> Poll> { -+ backend::Session::poll_recv_datagram(&mut self.0, cx).map_err(Error) -+ } -+ fn max_datagram_size(&self) -> usize { -+ backend::Session::max_datagram_size(&self.0) -+ } -+ fn binding(&self) -> moq::Binding { -+ moq::Binding::Uri -+ } -+ fn protocol(&self) -> Option<&str> { -+ backend::Session::protocol(&self.0) -+ } -+ fn close(&mut self, code: u32, reason: &str) { -+ backend::Session::close(&mut self.0, code, reason) -+ } -+ fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { -+ backend::Session::poll_closed(&mut self.0, cx).map(Error) -+ } -+ fn stats(&self) -> impl moq::Stats { -+ Stats(backend::Session::stats(&self.0)) -+ } -+} -+/// An outgoing WebTransport stream adapted to moq-net. -+pub struct SendStream(S); -+impl poll::SendStream for SendStream { -+ type Error = Error; -+ fn poll_write(&mut self, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { -+ backend::SendStream::poll_write(&mut self.0, cx, buf).map_err(Error) -+ } -+ fn poll_write_buf(&mut self, cx: &mut Context<'_>, buf: &mut B) -> Poll> { -+ backend::SendStream::poll_write_buf(&mut self.0, cx, buf).map_err(Error) -+ } -+ fn set_priority(&mut self, order: i32) { -+ backend::SendStream::set_priority(&mut self.0, order) -+ } -+ fn finish(&mut self) -> Result<(), Self::Error> { -+ backend::SendStream::finish(&mut self.0).map_err(Error) -+ } -+ fn reset(&mut self, code: u32) { -+ backend::SendStream::reset(&mut self.0, code) -+ } -+ fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { -+ backend::SendStream::poll_closed(&mut self.0, cx).map_err(Error) -+ } -+} -+/// An incoming WebTransport stream adapted to moq-net. -+pub struct RecvStream(S); -+impl poll::RecvStream for RecvStream { -+ type Error = Error; -+ fn poll_read(&mut self, cx: &mut Context<'_>, dst: &mut [u8]) -> Poll, Self::Error>> { -+ backend::RecvStream::poll_read(&mut self.0, cx, dst).map_err(Error) -+ } -+ fn poll_read_buf( -+ &mut self, -+ cx: &mut Context<'_>, -+ buf: &mut B, -+ ) -> Poll, Self::Error>> { -+ backend::RecvStream::poll_read_buf(&mut self.0, cx, buf).map_err(Error) -+ } -+ fn poll_read_chunk(&mut self, cx: &mut Context<'_>, max: usize) -> Poll, Self::Error>> { -+ backend::RecvStream::poll_read_chunk(&mut self.0, cx, max).map_err(Error) -+ } -+ fn stop(&mut self, code: u32) { -+ backend::RecvStream::stop(&mut self.0, code) -+ } -+ fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { -+ backend::RecvStream::poll_closed(&mut self.0, cx).map_err(Error) -+ } -+} -diff --git a/src/client.rs b/src/client.rs ---- a/src/client.rs -+++ b/src/client.rs -@@ -1,5 +1,5 @@ - //! Connecting to a relay: native only, like moq-ffi's QUIC client. The browser connects --//! through web-transport-wasm, which the slice leaves out. -+//! through web-transport-wasm instead, in `browser`. - - #[cfg(feature = "boltffi")] - use boltffi::export; -diff --git a/src/lib.rs b/src/lib.rs ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -26,6 +26,13 @@ - #[cfg(feature = "client")] - pub use client::*; - -+#[cfg(feature = "browser")] -+mod browser; -+#[cfg(all(feature = "browser", target_arch = "wasm32"))] -+mod browser_transport; -+#[cfg(feature = "browser")] -+pub use browser::*; -+ - // No thiserror: its derive claims BoltFFI's bare `#[error]` marker as its own attribute. - // `reason`, not `message`: UniFFI's Kotlin exception would clash with `Throwable.message`. - /// An error crossing the boundary. -@@ -187,6 +194,17 @@ - }) - } - -+ /// Start a group at `sequence`, for sequences past what JS numbers hold exactly. -+ pub fn create_group(&self, sequence: u64) -> Result { -+ let mut guard = self.inner.lock().unwrap(); -+ let track = guard.as_mut().ok_or(MoqError::Closed)?; -+ let group = track.create_group(moq_net::group::Info { sequence })?; -+ Ok(MoqGroupProducer { -+ sequence, -+ inner: Mutex::new(Some(group)), -+ }) -+ } -+ - /// End the track. - pub fn finish(&self) -> Result<(), MoqError> { - let mut guard = self.inner.lock().unwrap(); -@@ -342,6 +360,9 @@ - MoqTrackConsumer, - MoqGroupConsumer - ); -+ -+ #[cfg(feature = "browser")] -+ assume_single_threaded!(MoqBrowserSession, MoqBroadcastConsumer); - } - - #[cfg(target_arch = "wasm32")] diff --git a/test/wasm/slice/build.sh b/test/wasm/slice/build.sh deleted file mode 100755 index c2c35462b85..00000000000 --- a/test/wasm/slice/build.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# Bind bench/ffi-gen's moq-ffi slice for the browser twice: BoltFFI's web package, and -# UniFFI over ubrn's wasm2 player. Outputs land in slice/dist/{boltffi,uniffi}. -# -# Works on a scratch copy of the slice, so neither BoltFFI's output, browser.patch (a -# WebTransport client, so the slice reaches a relay), nor the UniFFI 0.31 pin (ubrn's -# version; see ../uniffi/build.sh) touches the tree. See ../generator.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-slice" -slice="$root/bench/ffi-gen" -boltffi_version=0.31.1 -ubrn_version=0.31.0-6 - -[[ -d "$slice" ]] || { - echo "error: $slice is missing; the slice lands with the BoltFFI spike (#5736)" >&2 - exit 1 -} - -# Skip the whole build when neither the slice, the overlay, nor this script changed. -stamp="$(cd "$slice" && find . -type f -not -path './target/*' | sort | xargs sha256sum | sha256sum) $(sha256sum <"$dir/browser.patch") $(sha256sum <"$0")" -if [[ -d "$dir/dist/boltffi" && -d "$dir/dist/uniffi" && "$(cat "$out/stamp" 2>/dev/null)" == "$stamp" ]]; then - exit 0 -fi - -boltffi="$out/tools/bin/boltffi" -[[ -x "$boltffi" ]] || cargo install --locked --quiet --root "$out/tools" "boltffi_cli@$boltffi_version" -# BoltFFI transpiles its TypeScript with `tsc`, resolved from an ancestor of its output. -[[ -d "$out/node_modules/@boltffi/runtime" ]] || - npm install --silent --no-save --prefix "$out" typescript@5 @types/node "@boltffi/runtime@$boltffi_version" -ubrn="$target/test/wasm-uniffi/tools/bin/uniffi-bindgen-react-native" -[[ -x "$ubrn" ]] || - cargo install --locked --quiet --root "$target/test/wasm-uniffi/tools" \ - --git https://github.com/jhugman/uniffi-bindgen-react-native --tag "$ubrn_version" uniffi-bindgen-react-native - -rm -rf "$out/src" "$dir/dist" -mkdir -p "$out/src" "$dir/dist" -cp -r "$slice/." "$out/src" -patch --quiet --directory "$out/src" --strip 1 <"$dir/browser.patch" -# The copy sits outside the repo, so it needs the repo's wasm32 cfgs (web-sys's WebTransport). -cp -r "$root/.cargo" "$out/src/.cargo" - -# BoltFFI: its CLI builds only `debug` or `release`, so release takes wasm-release's opt-level. -sed -i "s|../../target/ffi-gen/boltffi|$out/boltffi|" "$out/src/boltffi.toml" -(cd "$out/src" && PATH="$out/node_modules/.bin:$PATH" CARGO_TARGET_DIR="$out/cargo/boltffi" \ - CARGO_PROFILE_RELEASE_OPT_LEVEL=z "$boltffi" pack wasm --release --deny-skipped >/dev/null) -cp -r "$out/boltffi/npm/." "$dir/dist/boltffi" -# Where bun resolves the generated module's runtime import from when bundling. -mkdir -p "$dir/dist/node_modules/@boltffi" -cp -r "$out/node_modules/@boltffi/runtime" "$dir/dist/node_modules/@boltffi/" - -# UniFFI: the same pin moq-ffi takes, applied to the slice's own manifest. -sed -i 's|uniffi = { version = "0.32"|uniffi = { version = "=0.31"|' "$out/src/Cargo.toml" -sed -i "s|^web-async = .*|&\nuniffi-runtime-wasm = \"=$ubrn_version\"|" "$out/src/Cargo.toml" -printf '\n#[cfg(all(feature = "uniffi", target_arch = "wasm32"))]\nextern crate uniffi_runtime_wasm as _;\n' >>"$out/src/src/lib.rs" -cargo build --quiet --manifest-path "$out/src/Cargo.toml" --profile wasm-release --target wasm32-unknown-unknown \ - --no-default-features --features uniffi,browser --target-dir "$out/cargo/uniffi" -cat >"$out/package.json" <"$out/ubrn.config.yaml" </dev/null) -echo "$stamp" >"$out/stamp" diff --git a/test/wasm/slice/cases.ts b/test/wasm/slice/cases.ts deleted file mode 100644 index d60d9269392..00000000000 --- a/test/wasm/slice/cases.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Parity cases for the slice, identical for each generator: the subset of - * `uniffi/cases.ts` the slice's surface can express. - * - * @module - */ -import type { Open, Session, Slice } from "./adapter.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 Report { - generator: string; - /** Whether the page allows `new Function`. */ - eval: boolean; - cases: CaseResult[]; -} - -/** Above 2^53, so a lossy `number` conversion anywhere changes it; the largest QUIC varint. */ -const HIGH = [2n ** 53n + 1n, 2n ** 62n - 1n]; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -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); - } -} - -/** 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; -} - -/** An error's name and message, plus any structured fields a generator attaches. */ -function describe(err: unknown): string { - if (!(err instanceof Error)) return String(err); - const fields = JSON.stringify(err, (_, value) => (typeof value === "bigint" ? String(value) : value)); - return `${err.name}: ${err.message}${fields && fields !== "{}" ? ` ${fields}` : ""}`.slice(0, 300); -} - -async function connect(slice: Slice, 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:"; - return slice.connect(url.toString(), (await response.text()).trim(), relay.version); -} - -const local: Record Promise> = { - "u64 above 2^53 round-trips locally": async (slice) => { - for (const sequence of HIGH) { - const group = slice.group(sequence); - if (group.sequence() !== sequence) throw new Error(`sequence ${group.sequence()} != ${sequence}`); - group.write(new Uint8Array(1), sequence); - const frame = await group.read(); - if (frame?.timestampUs !== sequence) throw new Error(`timestamp ${frame?.timestampUs} != ${sequence}`); - } - return undefined; - }, - - "AbortSignal rejects a pending read without stealing the next frame": async (slice) => { - const group = slice.group(); - const abort = new AbortController(); - const first = group.read(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 ?? new Error("rejected with nothing"), - ); - if (!error) throw new Error("aborted read resolved"); - group.write(new Uint8Array(3)); - const next = await within(group.read(), 1000, "read after abort"); - if (next?.payload.byteLength !== 3) throw new Error("the frame after an abort went missing"); - return `rejects with ${describe(error)}`; - }, - - "freeing a handle mid-call": async (slice) => { - // Reported, not asserted: moq-wasm rejects the call, UniFFI keeps it running. - const group = slice.group(); - const read = group.read(); - group.free(); - if (!(await pending(read))) { - const outcome = await read.then( - () => "resolves", - (err: unknown) => `rejects with ${describe(err)}`, - ); - return `free settles the pending read: ${outcome}`; - } - group.write(new Uint8Array(2)); - const outcome = await within( - read.then( - (frame) => `resolves later with ${frame?.payload.byteLength} bytes`, - (err: unknown) => `rejects later with ${describe(err)}`, - ), - 1000, - "read after free", - ); - return `the call keeps running: ${outcome}`; - }, - - "closing the producer ends a pending read": async (slice) => { - const group = slice.group(); - const read = group.read(); - group.close(); - const outcome = await within( - read.then( - (frame) => (frame ? "a frame" : "end of group"), - (err: unknown) => `rejects with ${describe(err)}`, - ), - 1000, - "read after close", - ); - if (outcome === "a frame") throw new Error("closed group yielded a frame"); - return outcome; - }, -}; - -const remote: Record Promise> = { - "negotiates the relay's version": async (slice, relay) => { - (await connect(slice, relay)).cancel(); - return undefined; - }, - - "u64 above 2^53 crosses the relay": async (slice, relay) => { - const publisher = await connect(slice, relay); - const subscriber = await connect(slice, relay); - try { - const path = `slice/u64/${crypto.randomUUID()}`; - const publish = publisher.publish(path); - const track = await (await within(subscriber.broadcast(path), 5000, "announce")).subscribe("data"); - const seen: string[] = []; - for (const sequence of [5n, 2n ** 32n, 2n ** 53n - 1n, ...HIGH]) { - const group = publish.group(sequence); - group.write(new Uint8Array(160), sequence); - group.finish(); - const next = within(track.recvGroup(), 5000, `recvGroup ${sequence} after [${seen}]`); - // Known: on moq-lite-06 the largest varint never arrives, as with moq-ffi. - if (sequence === HIGH[1] && relay.version === "moq-lite-06") { - const outcome = await next.then( - (got) => `arrived as ${got.sequence()}`, - (err: unknown) => describe(err), - ); - 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; - if (got.sequence() !== sequence) throw new Error(`sequence ${got.sequence()} != ${sequence}`); - const frame = await got.read(); - // 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(); - subscriber.cancel(); - } - }, - - "refuses a track the publisher does not serve": async (slice, relay) => { - const publisher = await connect(slice, relay); - const subscriber = await connect(slice, relay); - try { - const path = `slice/refuse/${crypto.randomUUID()}`; - publisher.publish(path); - const broadcast = await within(subscriber.broadcast(path), 5000, "announce"); - const outcome = await settle( - broadcast.subscribe("missing").then((track) => track.recvGroup()), - 5000, - "refused subscribe", - () => "a group", - (err: unknown) => `rejected: ${describe(err)}`, - ); - if (outcome === "a group") throw new Error("a missing track yielded a group"); - return outcome; - } finally { - publisher.cancel(); - subscriber.cancel(); - } - }, - - "session cancel rejects a pending remote read": async (slice, relay) => { - const publisher = await connect(slice, relay); - const subscriber = await connect(slice, relay); - try { - const path = `slice/close/${crypto.randomUUID()}`; - const publish = publisher.publish(path); - const track = await (await within(subscriber.broadcast(path), 5000, "announce")).subscribe("data"); - const group = publish.group(0n); - group.write(new Uint8Array(4), 0n); - const got = await within(track.recvGroup(), 5000, "recvGroup"); - await got.read(); - const read = got.read(); - if (!(await pending(read))) throw new Error("read settled early"); - subscriber.cancel(); - const outcome = await settle( - read, - 5000, - "read after session cancel", - (frame) => (frame ? "a frame" : "end of group"), - (err: unknown) => `rejected: ${describe(err)}`, - ); - if (outcome === "a frame") throw new Error("read yielded a frame after cancel"); - return outcome; - } finally { - publisher.cancel(); - } - }, -}; - -async function run(generator: string, relays: Relay[]): Promise { - const { open } = (await import(`/slice-${generator}.js`)) as { open: Open }; - const slice = await open(new URL(`/slice/${generator}.wasm`, location.href)); - const cases: CaseResult[] = []; - const record = async (name: string, body: () => Promise) => { - try { - cases.push({ name, ok: true, note: await within(body(), 15000, name) }); - } catch (err) { - cases.push({ name, ok: false, error: describe(err) }); - } - }; - for (const [name, body] of Object.entries(local)) await record(name, () => body(slice)); - for (const relay of relays) - for (const [name, body] of Object.entries(remote)) - await record(`${relay.name}: ${name}`, () => body(slice, relay)); - let evaluates = true; - try { - new Function(""); - } catch { - evaluates = false; - } - return { generator, eval: evaluates, cases }; -} - -declare global { - interface Window { - sliceCases: typeof run; - } -} -window.sliceCases = run; diff --git a/test/wasm/slice/driver.ts b/test/wasm/slice/driver.ts deleted file mode 100644 index 26acae605ae..00000000000 --- a/test/wasm/slice/driver.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Run the slice's parity cases against every relay for each generator, normally and under a - * strict CSP, then its per-call bench, alternating generators across fresh pages so drift - * on a shared host hits both. Writes `slice.json` into the harness run directory and fails - * on any failed case. - */ -import { join } from "node:path"; -import { parseArgs } from "node:util"; -import { chromium } from "playwright"; -import type { Report } from "./bench.ts"; -import type { Report as CaseReport, Relay } from "./cases.ts"; - -const { values } = parseArgs({ options: { relays: { type: "string" } } }); -const run = process.env.MOQ_TEST_RUN; -if (!values.relays || !run) throw new Error("needs --relays and MOQ_TEST_RUN"); -const relays = (await Bun.file(values.relays).json()) as Relay[]; -const generators = ["uniffi", "boltffi"]; -// No 'unsafe-eval', as in uniffi/driver.ts. -const STRICT = "script-src 'self' 'wasm-unsafe-eval'"; -const rounds = Number(process.env.MOQ_SLICE_ROUNDS ?? 6); -const here = new URL(".", import.meta.url).pathname; -const dist = join(here, "../dist"); -const modules: Record = { - "/slice/uniffi.wasm": join(here, "dist/uniffi/moq_ffi_gen.wasm"), - "/slice/boltffi.wasm": join(here, "dist/boltffi/moq_ffi_gen_bg.wasm"), -}; - -const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - async fetch(req) { - const url = new URL(req.url); - const path = url.pathname; - if (path === "/" || path === "/cases") - return new Response(``, { - headers: { - "content-type": "text/html", - "cache-control": "no-store", - ...(url.searchParams.has("strict") ? { "content-security-policy": STRICT } : {}), - }, - }); - const file = Bun.file(modules[path] ?? join(dist, path)); - 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: Report[] = []; -const parity: (CaseReport & { mode: string })[] = []; -let failed = 0; -try { - for (const generator of generators) - for (const mode of ["jit", "strict"]) { - 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}/cases${mode === "strict" ? "?strict" : ""}`); - await page.waitForFunction(() => typeof window.sliceCases === "function"); - const report = await page.evaluate(([name, list]) => window.sliceCases(name, list), [ - generator, - relays, - ] as const); - if (errors.length) throw new Error(`${generator} ${mode}: ${errors.join("; ")}`); - if (report.eval !== (mode === "jit")) throw new Error(`${mode}: new Function allowed = ${report.eval}`); - parity.push({ ...report, mode }); - console.log(`── ${generator} ${mode}`); - 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}` : ""}`); - } - await context.close(); - } - - for (let round = 0; round < rounds; round++) { - // ABBA order: each generator goes first, and twice in a row, equally often. - const order = round % 2 ? [...generators].reverse() : generators; - for (const generator of order) { - const context = await browser.newContext(); - const page = await context.newPage(); - const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.message)); - await page.goto(`http://127.0.0.1:${server.port}/`); - await page.waitForFunction(() => typeof window.sliceBench === "function"); - const report = await page.evaluate((value) => window.sliceBench(value), generator); - if (errors.length) throw new Error(`${generator}: ${errors.join("; ")}`); - reports.push(report); - console.log(`round ${round} ${generator}: init ${report.init_ms.toFixed(1)}ms`); - await context.close(); - } - } -} finally { - await browser.close(); - server.stop(true); - await Bun.write(join(run, "slice.json"), JSON.stringify({ browser: version, parity, reports }, null, 2)); -} - -// Median per case and generator, with the range, as the report tables show them. -const cases = new Map>(); -for (const report of reports) - for (const bench of report.bench) { - const byGenerator = cases.get(bench.name) ?? new Map(); - byGenerator.set(report.generator, [...(byGenerator.get(report.generator) ?? []), bench.us_per_op]); - cases.set(bench.name, byGenerator); - } -const median = (values: number[]) => { - const sorted = [...values].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; -}; -const cell = (values: number[] = []) => - values.length - ? `${median(values).toFixed(2)} (${Math.min(...values).toFixed(2)}-${Math.max(...values).toFixed(2)})` - : "-"; -console.log("| case | UniFFI us | BoltFFI us |\n|---|---|---|"); -for (const [name, byGenerator] of cases) - console.log(`| ${name} | ${cell(byGenerator.get("uniffi"))} | ${cell(byGenerator.get("boltffi"))} |`); -if (failed) { - console.error(`${failed} slice case(s) failed`); - process.exit(1); -} diff --git a/test/wasm/slice/tsconfig.json b/test/wasm/slice/tsconfig.json deleted file mode 100644 index 827426f5cb9..00000000000 --- a/test/wasm/slice/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../js/tsconfig.json", - "compilerOptions": { - "types": ["bun"], - "noEmit": true - }, - "include": ["adapter.ts", "bench.ts", "boltffi.ts", "cases.ts", "driver.ts", "uniffi.ts"] -} diff --git a/test/wasm/slice/uniffi.ts b/test/wasm/slice/uniffi.ts deleted file mode 100644 index 7a6d5cd79e5..00000000000 --- a/test/wasm/slice/uniffi.ts +++ /dev/null @@ -1,79 +0,0 @@ -// The slice bound by UniFFI over ubrn's wasm2 player, behind the harness adapter. -import type { Frame, Open, RemoteGroup } from "./adapter.ts"; -import { - MoqBroadcastProducer, - MoqBrowserSession, - type MoqFrame, - type MoqGroupConsumer, - type MoqGroupConsumerLike, - noop, - noopAsync, - uniffiInitAsync, - yieldOnce, -} from "./dist/uniffi/index.ts"; - -// The harness hands whole-buffer payloads, so taking `.buffer` adds no JS copy. -const lower = (bytes: Uint8Array, timestampUs?: bigint): MoqFrame => ({ - payload: bytes.buffer as ArrayBuffer, - timestampUs, -}); -const lift = (frame: MoqFrame | undefined): Frame | undefined => - frame && { payload: new Uint8Array(frame.payload), timestampUs: frame.timestampUs }; -const opts = (signal?: AbortSignal) => (signal ? { signal } : undefined); -const remote = (group: MoqGroupConsumerLike): RemoteGroup => ({ - sequence: () => group.sequence(), - read: async (signal) => lift(await group.readFrame(opts(signal))), -}); - -export const open: Open = async (wasm) => { - await uniffiInitAsync(wasm); - return { - noop, - noopAsync: () => noopAsync(), - yieldOnce: () => yieldOnce(), - group(sequence) { - const broadcast = new MoqBroadcastProducer(); - const track = broadcast.publishTrack("data"); - const group = sequence === undefined ? track.appendGroup() : track.createGroup(sequence); - const consumer = group.consume(); - return { - sequence: () => consumer.sequence(), - write: (bytes, timestampUs = 0n) => group.writeFrame(lower(bytes, timestampUs)), - read: async (signal) => lift(await consumer.readFrame(opts(signal))), - // The generated `*Like` types omit uniffiDestroy; only the class declares it. - free: () => (consumer as MoqGroupConsumer).uniffiDestroy(), - close: () => { - group.finish(); - track.finish(); - }, - }; - }, - async connect(url, fingerprint, version) { - const session = await MoqBrowserSession.connect(url, fingerprint, version); - return { - async broadcast(path) { - const broadcast = await session.announcedBroadcast(path); - return { - async subscribe(name) { - const track = await broadcast.subscribeTrack(name); - return { recvGroup: async (signal) => remote(await track.recvGroup(opts(signal))) }; - }, - }; - }, - publish(path) { - const track = session.publishTrack(path, "data"); - return { - group(sequence) { - const group = track.createGroup(sequence); - return { - write: (bytes, timestampUs) => group.writeFrame(lower(bytes, timestampUs)), - finish: () => group.finish(), - }; - }, - }; - }, - cancel: () => session.cancel(), - }; - }, - }; -}; diff --git a/test/wasm/uniffi/pin.patch b/test/wasm/uniffi/pin.patch index ca93041fc46..a8f72f394a7 100644 --- a/test/wasm/uniffi/pin.patch +++ b/test/wasm/uniffi/pin.patch @@ -38,28 +38,3 @@ index 090bd368e..fd7013830 100644 uniffi::setup_scaffolding!("moq"); /// Stop the runtime workers before the host runtime goes away. -diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs -index dd54a9265..0fac18ea3 100644 ---- a/rs/moq-mux/src/clock.rs -+++ b/rs/moq-mux/src/clock.rs -@@ -43,6 +43,7 @@ fn moq_micros(wall: SystemTime) -> i128 { - } - - /// The host's wall clock, or `None` while it reads before the moq epoch (2020): unset. -+#[cfg(any(test, not(target_arch = "wasm32"), target_os = "wasi"))] - fn host_wall() -> Option { - #[cfg(test)] - let now = HOST_WALL.get().unwrap_or_else(SystemTime::now); -@@ -51,6 +52,12 @@ fn host_wall() -> Option { - (moq_micros(now) >= 0).then_some(now) - } - -+/// Spike only: std has no wall clock in the browser and panics, so leave it unset. -+#[cfg(all(not(test), target_arch = "wasm32", not(target_os = "wasi")))] -+fn host_wall() -> Option { -+ None -+} -+ - #[cfg(test)] - thread_local! { - /// What [`host_wall`] reads on this thread instead of the system clock. From 4aa2db0c10508ae37435f3bb238986af318281ea Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:46:08 -0700 Subject: [PATCH 09/10] test(wasm): bound each UniFFI case page by --timeout Co-Authored-By: Claude Opus 5.5 --- test/wasm/uniffi/driver.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/wasm/uniffi/driver.ts b/test/wasm/uniffi/driver.ts index 7ba00d2ec87..a1ff8c9d610 100644 --- a/test/wasm/uniffi/driver.ts +++ b/test/wasm/uniffi/driver.ts @@ -9,9 +9,13 @@ import { parseArgs } from "node:util"; import { chromium } from "playwright"; import type { Relay, Report } from "./cases.ts"; -const { values } = parseArgs({ options: { relays: { type: "string" }, timeout: { type: "string", default: "120" } } }); +// `--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; -if (!values.relays || !run) throw new Error("needs --relays and 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"); @@ -56,7 +60,10 @@ try { }); await page.goto(`http://127.0.0.1:${server.port}/${mode === "strict" ? "?strict" : ""}`); await page.waitForFunction(() => typeof window.uniffiCases === "function"); - const report = await page.evaluate((value) => window.uniffiCases(value), relays); + 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}`); @@ -78,3 +85,20 @@ 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); + } +} From d66f237104d91c7acd2e82b3667c325c965b97df Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:38:07 -0700 Subject: [PATCH 10/10] docs(bench): point ffi-gen's browser notes at the decided comparison Co-Authored-By: Claude Opus 5.5 --- bench/ffi-gen/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bench/ffi-gen/README.md b/bench/ffi-gen/README.md index 419e4280a56..71d33e1f741 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)).