diff --git a/.github/justfile b/.github/justfile index 6d395fdd0d3..21238a2f744 100644 --- a/.github/justfile +++ b/.github/justfile @@ -4,6 +4,10 @@ set working-directory := '..' cache: bash sh/gh/cache.sh +# Publish Smoke's runtime packages and their build artifacts to nix.moq.dev. +cache-runtime: + bash sh/gh/cache-runtime.sh + # Build the runner image with a temporary policy on a disposable hosted runner. runner-vm-ci: #!/usr/bin/env bash diff --git a/.github/workflows/cache.yml b/.github/workflows/cache.yml index 8a63655fb9b..13dcb685fbd 100644 --- a/.github/workflows/cache.yml +++ b/.github/workflows/cache.yml @@ -1,6 +1,6 @@ name: Cache -# Publish development environments independently of the landing worker's local caches. +# Publish dev shells and Smoke's runtime packages independently of the landing worker's local caches. permissions: contents: read @@ -33,3 +33,30 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.NIX_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NIX_R2_SECRET_ACCESS_KEY }} run: just gh cache + + runtime: + name: Runtime (ubuntu-24.04) + if: github.ref == 'refs/heads/main' + # Smoke's only current nix platform. Keep its Rust builds independent of + # the dev-shell matrix and use Smoke's cold-build budget. + runs-on: ubuntu-24.04 + timeout-minutes: 75 + environment: cache-write + # Finish the active publication; new pushes replace only the pending run. + # Canceling active builds could starve cold dependency uploads on busy main. + concurrency: + group: cache-runtime-${{ github.ref }} + cancel-in-progress: false + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/nix + - uses: ./.github/actions/just + - name: Cache runtime packages and dependencies + env: + NIX_SIGNING_KEY: ${{ secrets.NIX_SIGNING_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.NIX_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.NIX_R2_SECRET_ACCESS_KEY }} + run: just gh cache-runtime diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1e9495f7044..40743a13da4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -120,12 +120,15 @@ jobs: artifact-path: target/bench/nightly/rust-bench benchmark: true paired: true + # Report-only until quest/m1/bench-ci.md fixes the build bias behind + # an A/A false alarm; the site still lists its confirmed regressions. - name: Rust shared bench targets recipe: gh nightly rust-shared artifact: bench-rust-shared artifact-path: target/bench/nightly/rust-shared benchmark: true paired: true + report-only: true - name: Relay session benchmarks recipe: gh nightly rust-relay artifact: bench-rust-relay @@ -235,7 +238,7 @@ jobs: # After the upload, so the benchmark site still records tonight's history and # publishes the comparison that failed this job before the alert links to it. - name: Fail on paired regressions - if: ${{ matrix.paired && steps.workload.outcome == 'success' }} + if: ${{ matrix.paired && !matrix.report-only && steps.workload.outcome == 'success' }} run: nix develop --command just gh benchmark-report gate "${{ matrix.artifact-path }}" # gh-pages is the one Pages site, and each directory has one writer: this job owns @@ -466,30 +469,35 @@ jobs: persist-credentials: false - uses: ./.github/actions/nix - name: Rust cache + id: setup uses: ./.github/actions/rust-cache with: access-key-id: ${{ secrets.MBX_R2_ACCESS_KEY_ID }} secret-access-key: ${{ secrets.MBX_R2_SECRET_ACCESS_KEY }} + # Independent checks keep running after a failure; the job still finishes red. - name: WebSocket fallback startup benchmark run: nix develop --command just js qmux-bench - name: Browser publisher fairness benchmark + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: nix develop --command just js serve-bench shell: bash -leo pipefail {0} - name: Raw audio encoding benchmark + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: nix develop --command just js raw-audio-bench shell: bash -leo pipefail {0} - name: Audio ring benchmark + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: nix develop --command just js ring-bench shell: bash -leo pipefail {0} - name: Native headless playback - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: nix develop --command just test native-play shell: bash -leo pipefail {0} env: HARNESS_RUNS: ${{ runner.temp }}/moq-test-runs - name: Audio quality - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: nix develop --command just test audio-quality --runtimes chromium --enforce --capture shell: bash -leo pipefail {0} env: @@ -508,7 +516,7 @@ jobs: # stall voids about one AAC row in seven, which would fail most nights until # it is fixed. Kept beside the browser run so its ceilings can be re-measured. - name: Native audio quality - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: nix develop --command just test audio-quality --runtimes native shell: bash -leo pipefail {0} env: @@ -517,7 +525,7 @@ jobs: # The run directory is the diagnosis: each process's log, the shaper's # counters, the raw samples, and every row's summary. - name: Keep the run - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: audio-quality-run diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index a178c017e75..f72fcb6dd0a 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -106,14 +106,16 @@ jobs: - name: Setup complete id: setup run: echo "Platform tools ready" - - name: Rust compile and tests + - name: Rust compile if: ${{ !cancelled() && steps.setup.outcome == 'success' && (needs.scope.outputs.rs == 'true') }} shell: bash env: CI_BASE: ${{ github.event_name != 'pull_request' && '--all' || '' }} - run: | - just rs windows "$CI_BASE" - just rs windows-test + run: just rs windows "$CI_BASE" + - name: Rust tests + if: ${{ !cancelled() && steps.setup.outcome == 'success' && (needs.scope.outputs.rs == 'true') }} + shell: bash + run: just rs windows-test - name: C if: ${{ !cancelled() && steps.setup.outcome == 'success' && (needs.scope.outputs.c == 'true') }} shell: bash @@ -159,13 +161,14 @@ jobs: - name: Setup complete id: setup run: echo "Platform tools ready" - - name: Rust compile and tests + - name: Rust compile if: ${{ !cancelled() && steps.setup.outcome == 'success' && (needs.scope.outputs.rs == 'true') }} env: CI_BASE: ${{ github.event_name != 'pull_request' && '--all' || '' }} - run: | - just rs macos "$CI_BASE" - just rs macos-test + run: just rs macos "$CI_BASE" + - name: Rust tests + if: ${{ !cancelled() && steps.setup.outcome == 'success' && (needs.scope.outputs.rs == 'true') }} + run: just rs macos-test - name: Swift simulator check if: ${{ !cancelled() && steps.setup.outcome == 'success' && (needs.scope.outputs.swift == 'true') }} run: just swift simulator diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index a6af6efe653..d40964896a2 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -41,8 +41,8 @@ jobs: runs-on: ${{ matrix.os }} # The matrix runs in real time (ffmpeg -re pacing + per-subscriber timeouts); # this is just a safety net against a hung relay/client. The nix channel - # gets longer: nix.moq.dev serves the toolchain but not moq's packages at - # HEAD, and compiling them has taken 43 minutes. + # gets longer for a cold build when Cache has not finished publishing + # HEAD's packages yet; compiling them has taken 43 minutes. timeout-minutes: ${{ matrix.channel == 'nix' && 75 || 45 }} # smoke.sh resolves the latest libmoq release via the GitHub API. Without a @@ -74,7 +74,8 @@ jobs: - os: ubuntu-latest channel: docker # macOS cells temporarily disabled to cut CI spend (over budget this - # month). Re-enable by uncommenting; nothing else depends on them. + # month). Re-enable by uncommenting; the nix cell also needs its + # platform added to cache.yml's runtime job. # - os: macos-latest # channel: cargo # - os: macos-latest diff --git a/AGENTS.md b/AGENTS.md index 1d7704bb98c..532551286e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ The API is the most important thing to get right: a bad shape costs a breaking c - Propose alternatives when a name or shape feels awkward. - Short names under a module namespace (`encode::Config`, not `EncoderConfig`), mirrored across Rust, JS, and the bindings. - Keep package boundaries modular and reusable; prefer a module in an existing package over a one-off package. +- Never use a huge value like `u64::MAX` as a marker (it is the longest varint); use 0, a separate message or type, or a boolean. # Development diff --git a/Cargo.lock b/Cargo.lock index 60d2696e8e9..455b5dc925b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4708,6 +4708,7 @@ dependencies = [ name = "moq-msf" version = "0.5.1" dependencies = [ + "bench-harness", "serde", "serde_json", "serde_with", @@ -4790,6 +4791,7 @@ dependencies = [ "proptest", "rand 0.10.3", "rangemap", + "rind", "serde", "serde_json", "slab", @@ -4798,6 +4800,7 @@ dependencies = [ "tracing", "url", "workspace-hack", + "zeroize", ] [[package]] @@ -4915,6 +4918,7 @@ dependencies = [ "libc", "moq-auth 0.2.1", "moq-congestion", + "moq-crypto", "moq-net", "moq-quic", "moq-shaper", diff --git a/bench/README.md b/bench/README.md index 25f0f1a07cb..8d01957feaa 100644 --- a/bench/README.md +++ b/bench/README.md @@ -203,7 +203,7 @@ also run during timing. Every timed round reads and checks all payload bytes, returns lease credit, and fills each stream again. Throughput counts delivered payload once. Nightly's supplemental Rust benchmark leg records the series and keeps the memory output with its raw results. The broader MoQ session transport -and version sweep remains in [its quest](/quest/m2/bench-transport-sweep.md). +and version sweep remains in [its quest](/quest/m4/bench-transport-sweep.md). Compare one multi-threaded Tokio runtime with the same number of independent Tokio/epoll and io\_uring workers: @@ -890,8 +890,8 @@ the table requires no new renderer or data branch. A source's `paired.json`, as ### Paired comparison -Each Nightly's Rust bench groups (`rust-bench`, `rust-shared`, `rust-extra` and -`rust-uring`) also build the previous scheduled Nightly's commit beside their +Each Nightly's Rust bench groups (`rust-bench`, `rust-shared`, `rust-relay`, +`rust-extra` and `rust-uring`) also build the previous scheduled Nightly's commit beside their own, on the same runner, so night-to-night runner variance drops out. Each built target runs once per side in the trend's quick mode, alternating which side goes first; the current side is the trend point. A case more than 10% @@ -901,8 +901,21 @@ measurement, 30 samples). A case whose rounds are all slower with a median beyond 10% is a confirmed regression and fails its job after the upload, so the Alert workflow posts the failed Nightly. A target the base cannot build or run fails the job too, rather than passing uncompared; a bench target the base does -not have yet is only reported as new. The threshold is a starting point until -A/A runs on the hosted runner measure its noise floor. +not have yet is only reported as new. `rust-shared` is report-only for now: its +confirmed regressions show on the site but do not fail the job, until the +build bias below is fixed. + +Runs on the hosted runner set these numbers. In four A/A runs of the +`rust-shared`, `rust-extra` and `rust-uring` groups, and two of `rust-relay`, +1 to 9% of cases crossed 10% either way in the screen, at most 17 of them +slower in one group. The rounds rejected all but one: +`wire_decode/padded/packets_64` at +12%, which read +2% in the next A/A run. +A run whose base cut one case's work by a quarter failed with that case alone +at +35%; its rounds also agreed, at medians of +9.3 to +9.7%, on `stats/tick` +cases whose source was identical on both sides. Separate builds of the same +source can measure up to 12% apart, so the threshold cannot go lower until +that bias is fixed. +`rust-bench` is not calibrated yet: its paired leg outlasts its hour. The table page shows tonight's comparison above the trend: confirmed regressions, targets not compared, cases faster in the screen, slowdowns the @@ -923,6 +936,11 @@ Each run's evidence directory keeps both builds' estimates (`criterion` and `paired.json`. The base build streams its diagnostics and retains them in `base-build.log`, including when it fails. +Each revision builds in its own Cargo target directory (`base` and `current` +under `CARGO_TARGET_DIR`, default `target/bench`). For an A/B comparison, use +`run.sh`'s `BENCH_BASE` mode or a target directory per source tree: two trees +sharing one can leave a bench linked against the other tree's library. + ### CodSpeed The CodSpeed workflow adds instruction counts for every `main` commit that @@ -970,3 +988,12 @@ OOM kill does too, even inside a Quest scope: each worker watches `local.py` through a pidfd and stops once it exits. macOS has no pidfd, so there killing `local.py` with SIGKILL frees the lock while its workers keep measuring until their own gates exit, an accepted gap. Stop it with Ctrl-C or SIGTERM instead. + +### Python socket sessions + +The Python binding runner also sweeps both TCP and track sockets over 1, 4, +and 16 accepting endpoints and 1, 4, and 16 concurrent sessions per endpoint. +Each session sends a checked payload and finishes both halves. The +`socket//endpoints=N/sessions=M` entries report complete sessions per +second, including handshake, FFI calls, reads, and teardown. Run with +`just py bench`; Nightly's binding lane runs the same sweep. diff --git a/bench/browser/page/stats.ts b/bench/browser/page/stats.ts index 1af8a34d759..cee88d035c7 100644 --- a/bench/browser/page/stats.ts +++ b/bench/browser/page/stats.ts @@ -91,7 +91,7 @@ async function allocator(connection: Moq.Connection): Promise { @@ -149,7 +149,7 @@ async function setup(config: Sampling): Promise { /** Publishes one small-frame track and plays it on every connection. */ async function receive(config: Sampling): Promise { const publishing = new Moq.Origin.Producer(); - const publication = publishing.createBroadcast(Moq.Path.from(`${config.path}/live`)); + const publication = publishing.route(Moq.Path.from(`${config.path}/live`), { hidden: true }); const track = publication.broadcast.createTrack("data", { timescale: Moq.Time.Timescale.MILLI }); publication.announce(); const publisher = await Moq.Connection.connect({ @@ -249,7 +249,7 @@ async function resume(config: Sampling & { cycles: number; idle_ms: number; limi const waits: number[] = []; for (let cycle = 0; cycle < config.cycles; cycle++) { await new Promise((resolve) => setTimeout(resolve, config.idle_ms)); - const publication = origin.createBroadcast(Moq.Path.from(`${config.path}/resume/${cycle}`)); + const publication = origin.route(Moq.Path.from(`${config.path}/resume/${cycle}`), { hidden: true }); const track = publication.broadcast.createTrack("video", { priority: 1 }); const start = performance.now(); const reservation = handle.reserve(track.demand(), 2_500_000); diff --git a/bench/browser/page/transport.ts b/bench/browser/page/transport.ts index bbfce615db6..d188ee28dc2 100644 --- a/bench/browser/page/transport.ts +++ b/bench/browser/page/transport.ts @@ -32,7 +32,7 @@ async function publish(config: Transport): Promise { // Fixture generation stays outside every timed interval. const fixtures = Array.from({ length: config.frames }, (_, frame) => payload(config.size, frame)); const origin = new Moq.Origin.Producer(); - const publication = origin.createBroadcast(Moq.Path.from(config.path)); + const publication = origin.route(Moq.Path.from(config.path), { hidden: true }); const producer = publication.broadcast; // Retention covers the whole run, so a slow consumer cannot lose a group to eviction. const track = producer.createTrack("data", { timescale: Moq.Time.Timescale.MILLI, maxAge: Milli(600_000) }); diff --git a/bench/compare.py b/bench/compare.py index 9f5b66f6786..82d2c634010 100644 --- a/bench/compare.py +++ b/bench/compare.py @@ -157,7 +157,8 @@ def criterion_baseline(directory, discovery, benchmark): # A paired case beyond this change is flagged, and fails the nightly only when its rounds agree. -# Loose until A/A runs on the hosted runner measure its noise floor. +# Separate builds of unchanged code measured up to 12% apart in A/A runs, so lowering this +# waits on fixing that bias (quest/m1/bench-ci.md). THRESHOLD_PCT = 10 # Each alternating round costs seconds per case, so a noisy night confirms only its worst slowdowns. CONFIRMATIONS = 20 diff --git a/bench/fanout/session/Cargo.lock b/bench/fanout/session/Cargo.lock new file mode 100644 index 00000000000..2d9f2ec521d --- /dev/null +++ b/bench/fanout/session/Cargo.lock @@ -0,0 +1,840 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fanout-session" +version = "0.0.0" +dependencies = [ + "bytes", + "futures", + "kio", + "moq-net", + "tokio", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "kio" +version = "0.6.2" +dependencies = [ + "crossbeam-epoch", + "kio-macros", + "rand", + "smallvec", + "tokio", +] + +[[package]] +name = "kio-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "moq-net" +version = "0.3.10" +dependencies = [ + "arrayvec", + "bytes", + "flate2", + "futures", + "hashbrown", + "hashlink", + "kio", + "moq-pattern", + "num_enum", + "rand", + "rangemap", + "serde", + "slab", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "moq-pattern" +version = "0.1.2" +dependencies = [ + "serde", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/bench/fanout/session/src/main.rs b/bench/fanout/session/src/main.rs index 6c841192bd7..03e7f3d27fb 100644 --- a/bench/fanout/session/src/main.rs +++ b/bench/fanout/session/src/main.rs @@ -63,10 +63,10 @@ fn main() { .with_subscriber(relay.clone()), ) .await; - let broadcast = publisher.create_broadcast("room").unwrap(); + let broadcast = publisher.route("room", moq_net::origin::RouteOptions::hidden()).unwrap(); let info = moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI); - let mut track = broadcast.create_track("chat", info).unwrap(); - broadcast.announce(Default::default()).unwrap(); + let mut track = broadcast.broadcast().create_track("chat", info).unwrap(); + broadcast.announce().unwrap(); let mut audience = Vec::new(); for viewer in 0..viewers { diff --git a/c/moq/MIGRATE.md b/c/moq/MIGRATE.md index 8c738b9bda8..98cd2db4c41 100644 --- a/c/moq/MIGRATE.md +++ b/c/moq/MIGRATE.md @@ -4,6 +4,27 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `moq_connection_stats` counts unrouted datagrams ([#7951](https://github.com/moq-dev/moq/pull/7951)) + +`moq_connection_stats` gains `datagrams_unrouted` and `datagrams_unrouted_valid` at its end, which changes its ABI: recompile against the new header. +`datagrams_unrouted` counts the datagrams received for a subscription with no datagram track, and is valid on versions with datagrams (moq-lite 05 and newer). + +### Socket sessions + +The generated socket endpoint, incoming dial, and stream handles now expose TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +### Publishing goes through one `moq_route` handle + +`moq_origin_producer_create_broadcast(origin, path, &publication)` is `moq_origin_producer_route(origin, path, &options, &route)`, and `moq_origin_producer_requests(origin, prefix, &requests)` is `moq_origin_producer_route_prefix(origin, prefix, &options, &route)`; start `options` from `moq_route_options_default()`. A `moq_route` replaces `moq_publication`, `moq_origin_requests`, and `moq_broadcast_prepared`: + +- `moq_publication_broadcast(publication)` is `moq_route_broadcast(route, &broadcast)`, which fails on a prefix route. +- `moq_publication_announce(publication, &route)` is `moq_route_announce(route)`, and re-pricing is `moq_route_set_cost(route, cost)`; `moq_publication_unannounce` is `moq_route_withdraw`. +- `moq_origin_requests_requested_broadcast` is `moq_route_requested`, whose value is NULL once the origin closes. +- `moq_broadcast_request_prepare` then `moq_broadcast_prepared_publish` is `moq_broadcast_request_route(request, &options, &route)`: create it hidden to fill it first, then announce it. +- `moq_route_instance_id(route)` returns the stream's instance, kept across withdraw and announce. + +`moq_announce_route` is `moq_route_options`, given at creation, with a leading `hidden`. The default route is visible at once, so set `hidden` and call `moq_route_announce` after filling the catalog to keep the old order. Hold the route while the broadcast should stay announced: `moq_route_cancel` or freeing its last handle retracts it, and `moq_broadcast_producer_close` ends both. The `moq_route` record on `moq_announce.route` is `moq_route_info`. + ### Separate datagram tracks Create a separate producer with `moq_publish_datagrams` and `moq_datagram_info`; replace `moq_publish_track_datagram` with `moq_publish_datagrams_append` on that handle, or `moq_publish_datagrams_insert` for an explicit sequence. `moq_consume_datagrams` now takes a subscriber priority before its callback and never subscribes to a stream track. Use `moq_publish_datagrams_demand` and `moq_datagram_demand_priority` for aggregate priority, and `moq_publish_datagrams_requests` / `moq_datagram_request_accept` for on-demand publishing. Finish or abort datagram producers separately. A zeroed datagram info is untimed, unlike the microsecond default of stream-track info. diff --git a/c/moq/test/probe.c b/c/moq/test/probe.c index 5ab02854386..6ca21f6ee1f 100644 --- a/c/moq/test/probe.c +++ b/c/moq/test/probe.c @@ -288,18 +288,22 @@ int main(void) /* Publisher: a server whose origin serves one broadcast with one track. */ moq_origin_config origin_config = moq_origin_config_default(); moq_origin_producer *origin = moq_origin_producer_new(&origin_config); - moq_publication *publication = NULL; - ok(moq_origin_producer_create_broadcast(origin, "probe", &publication), "create_broadcast"); - moq_broadcast_producer *broadcast = moq_publication_broadcast(publication); + /* Hidden until its track exists; the route keeps it announced while it is held. */ + moq_route_options route_options = moq_route_options_default(); + route_options.hidden = true; + moq_route *route = NULL; + ok(moq_origin_producer_route(origin, "probe", &route_options, &route), "route"); + moq_broadcast_producer *broadcast = NULL; + ok(moq_route_broadcast(route, &broadcast), "route broadcast"); + char *instance = moq_route_instance_id(route); + CHECK(instance && instance[0] != '\0'); + moq_string_free(instance); moq_track_info info = moq_track_info_default(); info.timescale.has_value = true; info.timescale.value = 1000000; moq_track_producer *track = NULL; ok(moq_broadcast_producer_publish_track(broadcast, "data", &info, &track), "publish_track"); - moq_announce_route route = moq_announce_route_default(); - ok(moq_publication_announce(publication, &route), "announce"); - /* Releasing the publication keeps the route: the subscriber below still reaches it. */ - moq_publication_free(publication); + ok(moq_route_announce(route), "announce"); const char *generate[] = {"localhost"}; const char *versions[] = {"moq-lite-07-wip"}; @@ -419,6 +423,7 @@ int main(void) moq_server_free(server); moq_track_producer_free(track); moq_broadcast_producer_free(broadcast); + moq_route_free(route); moq_origin_producer_free(origin); /* A first dial that keeps failing still returns the session, whose state names each failed @@ -455,10 +460,13 @@ int main(void) * while moq_shutdown drains the dispatcher; none may run once it returns, even as the * handles are freed afterward. */ moq_origin_producer *late_origin = moq_origin_producer_new(&origin_config); - moq_publication *late_publication = NULL; - ok(moq_origin_producer_create_broadcast(late_origin, "late", &late_publication), "late broadcast"); - moq_broadcast_producer *late_broadcast = moq_publication_broadcast(late_publication); - moq_publication_free(late_publication); + moq_route_options late_options = moq_route_options_default(); + late_options.hidden = true; + moq_route *late_route = NULL; + ok(moq_origin_producer_route(late_origin, "late", &late_options, &late_route), "late route"); + moq_broadcast_producer *late_broadcast = NULL; + ok(moq_route_broadcast(late_route, &late_broadcast), "late broadcast"); + moq_route_free(late_route); moq_track_producer *late_track = NULL; ok(moq_broadcast_producer_publish_track(late_broadcast, "data", &info, &late_track), "late track"); moq_track_consumer *late = NULL; diff --git a/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index b3a688e0dca..7850dce4479 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -2,10 +2,28 @@ ## Unreleased +### Socket sessions + +The generated SocketEndpoint, SocketIncomingTcp, SocketIncomingTrack, SocketTcpStream, and SocketTrackStream handles now expose TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`moq::GroupProducer::ready(size)` is a new async call that completes once a frame of `size` bytes fits a window ahead of the fastest reader. `co_await` it before each `write_frame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following::available()` reports the path going offline ([#7678](https://github.com/moq-dev/moq/pull/7678)) `moq::Following::available()` resolves to a null `std::shared_ptr` once the path goes offline (or, for a follow pinned to an instance, once another instance takes it over), then the next broadcast when something serves the path again. The first call is never null. Check for null before using a later result, and treat it like the previous instance ending. +### Publishing goes through one `moq::Route` handle + +`OriginProducer::create_broadcast(path)` is `route(path, options)`, and `requests(prefix)` is `route_prefix(prefix, options)`; `{}` options are the default. Each returns a `std::shared_ptr`, which replaces `moq::Publication`, `moq::OriginRequests`, and `moq::BroadcastPrepared`: + +- `Publication::broadcast()` is `Route::broadcast()`, which returns an error on a prefix route. +- `Publication::announce(route)` is `announce()`, and re-pricing is `set_cost(cost)`; `unannounce()` is `withdraw()`. +- `OriginRequests::requested_broadcast()` is `requested()`, which resolves to `nullptr` once the origin closes. +- `BroadcastRequest::prepare()` then `BroadcastPrepared::publish()` is `BroadcastRequest::route(options)`: create it hidden to fill it first, then `announce()`. +- `instance_id()` is the stream's instance, kept across `withdraw()` and `announce()`. + +`moq::AnnounceRoute` is `moq::RouteOptions{hidden, cost, instance, epoch}`, given at creation. A route is visible at once unless `hidden`; set it and call `announce()` after filling the catalog to keep the old order. Hold the route while the broadcast should stay announced: `cancel()` or releasing its last handle retracts it, and closing its broadcast ends both. `moq::Route`, the record on `moq::Announce::route`, is `moq::RouteInfo`. + ### Separate datagram tracks Replace stream-track datagram methods with `BroadcastProducer::create_datagrams(name, info)` and `BroadcastConsumer::datagrams(name)`. The generated `DatagramProducer` exposes `append(timestamp, payload)` and `insert(Datagram)`; `DatagramConsumer::recv()` returns an optional datagram through a future. `DatagramInfo` has only priority and optional timescale. Demand is aggregate subscriber `priority()` / `priority_changed(last)`; on-demand datagram requests come from `BroadcastRequests::datagrams()` / `DatagramRequests::requested()`. Stream tracks of the same name are independent. diff --git a/cpp/moq/bench/main.cpp b/cpp/moq/bench/main.cpp index 91653e4326c..dde2857309f 100644 --- a/cpp/moq/bench/main.cpp +++ b/cpp/moq/bench/main.cpp @@ -98,7 +98,10 @@ Reader read(Case c, std::shared_ptr consumer, const std::vec void throughput(Case c, double budget, std::vector &results) { auto origin = moq::OriginProducer::init({uint64_t(64 << 20)}); - auto broadcast = ok(origin->create_broadcast("bench"))->broadcast(); + moq::RouteOptions hidden; + hidden.hidden = true; + auto route = ok(origin->route("bench", hidden)); + auto broadcast = ok(route->broadcast()); std::vector> tracks; std::vector> consumers; moq::TrackInfo info; diff --git a/cpp/moq/test/probe.cpp b/cpp/moq/test/probe.cpp index ffea5252058..e4f325ebade 100644 --- a/cpp/moq/test/probe.cpp +++ b/cpp/moq/test/probe.cpp @@ -293,24 +293,23 @@ int main() // It prints the message Rust's Display gives. CHECK(fingerprints.error().to_string() == "bind: not listening; call listen() first"); - // Publisher: a server whose origin serves one broadcast with one track. Dropping the - // publication keeps the route, which lasts until the broadcast closes. + // Publisher: a server whose origin serves one broadcast with one track. The route, created + // hidden until its track exists, keeps the broadcast announced while it is held. auto origin = moq::OriginProducer::init({}); - std::shared_ptr broadcast; - std::shared_ptr track; - { - auto publication = ok(origin->create_broadcast("probe"), "create_broadcast"); - broadcast = publication->broadcast(); - moq::TrackInfo info; - info.timescale = 1000000; - track = ok(broadcast->publish_track("data", info), "publish_track"); - ok(publication->announce({}), "announce"); - } - - // A closed broadcast's publication can no longer announce. - auto stale = ok(origin->create_broadcast("stale"), "create_broadcast"); - ok(stale->broadcast()->close(), "close stale broadcast"); - auto reannounced = stale->announce({}); + moq::RouteOptions hidden; + hidden.hidden = true; + auto route = ok(origin->route("probe", hidden), "route"); + CHECK(!route->instance_id().empty()); + auto broadcast = ok(route->broadcast(), "route broadcast"); + moq::TrackInfo info; + info.timescale = 1000000; + auto track = ok(broadcast->publish_track("data", info), "publish_track"); + ok(route->announce(), "announce"); + + // A route whose broadcast closed can no longer announce. + auto stale = ok(origin->route("stale", {}), "route"); + ok(ok(stale->broadcast(), "stale broadcast")->close(), "close stale broadcast"); + auto reannounced = stale->announce(); CHECK(!reannounced); CHECK(std::holds_alternative(reannounced.error().get_variant())); diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index 21699589dd2..02eb02b029f 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -1124,6 +1124,11 @@ void MoQDock::StartStream() return; } + if (auto *moq = static_cast(obs_obj_get_data(output))) { + const auto instance = moq->GetInstance(); + if (!instance.empty()) + publishSummary += QStringLiteral("\nInstance ") + QString::fromStdString(instance); + } pollTimer->start(); SetRunning(true); diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 95bfb576647..df628cfda75 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -166,19 +166,20 @@ bool MoQOutput::Start() return false; } - // Create the broadcast on an origin of our own, then announce it so subscribers - // can discover it. Stop() finishes it, so each Start creates a fresh one, under a - // fresh instance the broadcast mints and keeps across reconnects. Only the - // broadcast is kept: the route outlives the publication until the broadcast closes. + // Route the broadcast on an origin of our own, visible at once so subscribers can + // discover it. Stop() closes it, so each Start creates a fresh route, under a fresh + // instance it mints and keeps across reconnects. The route is held until Reset(): + // releasing it would retract the broadcast. LOG_INFO("Publishing broadcast: %s", path.c_str()); auto next_origin = moq::OriginProducer::init(moq::OriginConfig{}); - auto publication = next_origin->create_broadcast(path); - if (!publication) { - LOG_ERROR("Failed to create broadcast: %s", publication.error().to_string().c_str()); + auto next_route = next_origin->route(path, {}); + if (!next_route) { + LOG_ERROR("Failed to create broadcast: %s", next_route.error().to_string().c_str()); return false; } - if (auto announced = (*publication)->announce({}); !announced) { - LOG_ERROR("Failed to announce broadcast: %s", announced.error().to_string().c_str()); + auto next_broadcast = (*next_route)->broadcast(); + if (!next_broadcast) { + LOG_ERROR("Failed to create broadcast: %s", next_broadcast.error().to_string().c_str()); return false; } @@ -228,11 +229,13 @@ bool MoQOutput::Start() { std::lock_guard lock(mutex); url = server; + instance = (*next_route)->instance_id(); } { std::lock_guard lock(media_mutex); origin = next_origin; - broadcast = (*publication)->broadcast(); + route = *next_route; + broadcast = *next_broadcast; } // NOTE: You could publish the same broadcasts to multiple sessions if you want (redundant ingest). @@ -369,6 +372,7 @@ void MoQOutput::Reset() connected = false; live = false; url.clear(); + instance.clear(); last_failure_code = MoQError::None; last_failure_reason.clear(); token_refusal.clear(); @@ -403,6 +407,7 @@ void MoQOutput::Reset() if (broadcast) broadcast->close(); broadcast.reset(); + route.reset(); origin.reset(); // The session owns the drain after this request, even if the output dies now. @@ -425,6 +430,12 @@ void MoQOutput::Reset() SetBitrate(encoder, bitrate); } +std::string MoQOutput::GetInstance() +{ + std::lock_guard lock(mutex); + return instance; +} + bool MoQOutput::TryGetConnectionStats(ConnectionStats *out) { if (!out) diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index 796c5e516e1..333528baf39 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -59,6 +59,9 @@ class MoQOutput { // Prefer this over obs_output_get_connect_time_ms, which stays 0 for sub-ms connects. bool IsLiveSession(); + // The route's instance for this Start(), kept through session reconnects; empty when stopped. + std::string GetInstance(); + // Most recent connect/reconnect failure for this Start(), or empty when none. // Thread-safe; the dock polls this while connecting, while reconnecting, and on stop. void CopyLastFailure(int *code, std::string *reason); @@ -184,6 +187,7 @@ class MoQOutput { bool connected = false; bool live = false; std::string url; + std::string instance; int last_failure_code = 0; std::string last_failure_reason; std::string token_refusal; @@ -195,6 +199,8 @@ class MoQOutput { // Never held across an OBS call that can wait on an encoder thread. std::mutex media_mutex; std::shared_ptr origin; + // Keeps the broadcast announced; the broadcast closing retracts it. + std::shared_ptr route; std::shared_ptr broadcast; std::map video_tracks; std::map audio_tracks; diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index e656fa88d5e..91733783cbb 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -611,18 +611,15 @@ moq_source_ranked_audio(const std::unordered_map &rendi }); } -// The ranked renditions to try in turn: the enabled ones. With none enabled, a consumer -// should not select a rendition but holds the one it has, so only those `held` accepts. -template +// The ranked renditions to try in turn: the enabled ones, since a consumer must not select a +// disabled rendition. +template static std::vector *> -moq_source_candidates(std::vector *> ranked, Held held) +moq_source_candidates(std::vector *> ranked) { - const bool any_enabled = - std::any_of(ranked.begin(), ranked.end(), [](const auto *entry) { return entry->second.enabled; }); - ranked.erase( - std::remove_if(ranked.begin(), ranked.end(), - [&](const auto *entry) { return any_enabled ? !entry->second.enabled : !held(*entry); }), - ranked.end()); + ranked.erase(std::remove_if(ranked.begin(), ranked.end(), + [](const auto *entry) { return !entry->second.enabled; }), + ranked.end()); return ranked; } @@ -976,10 +973,7 @@ static void moq_source_subscribe_track(struct moq_source *ctx, const std::shared static void moq_source_subscribe_video(struct moq_source *ctx, const std::shared_ptr &conn, const moq::Catalog &catalog) { - const auto candidates = moq_source_candidates(moq_source_ranked_video(catalog.video), [&](const auto &entry) { - return moq_source_same_video(conn->video.get(), entry.first, entry.second) || - moq_source_same_video(conn->pending_video.get(), entry.first, entry.second); - }); + const auto candidates = moq_source_candidates(moq_source_ranked_video(catalog.video)); if (candidates.empty()) { LOG_INFO("Catalog has no enabled video rendition; audio only"); moq_source_clear_video_locked(ctx); @@ -1021,10 +1015,7 @@ static void moq_source_subscribe_video(struct moq_source *ctx, const std::shared static void moq_source_subscribe_audio(struct moq_source *ctx, const std::shared_ptr &conn, const moq::Catalog &catalog) { - const auto candidates = moq_source_candidates(moq_source_ranked_audio(catalog.audio), [&](const auto &entry) { - return moq_source_same_audio(conn->audio.get(), entry.first, entry.second) || - moq_source_same_audio(conn->pending_audio.get(), entry.first, entry.second); - }); + const auto candidates = moq_source_candidates(moq_source_ranked_audio(catalog.audio)); if (candidates.empty()) { LOG_INFO("Catalog has no enabled audio rendition; video only"); moq_source_clear_audio_locked(ctx); diff --git a/cpp/obs/test/moq-audio-encoder-test.cpp b/cpp/obs/test/moq-audio-encoder-test.cpp index f7f19fd9b15..682658d1ebb 100644 --- a/cpp/obs/test/moq-audio-encoder-test.cpp +++ b/cpp/obs/test/moq-audio-encoder-test.cpp @@ -203,9 +203,8 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) return; auto origin = moq::OriginProducer::init(moq::OriginConfig{}); - auto publication = TestOk(origin->create_broadcast("room"), "create_broadcast"); - auto broadcast = publication->broadcast(); - TestOk(publication->announce({}), "announce"); + auto route = TestOk(origin->route("room", {}), "route"); + auto broadcast = TestOk(route->broadcast(), "route broadcast"); moq::AudioInit init{}; init.format = moq::AudioFormat::kOpus; diff --git a/cpp/obs/test/moq-source-test.cpp b/cpp/obs/test/moq-source-test.cpp index 16ad9730569..740e0bb3e77 100644 --- a/cpp/obs/test/moq-source-test.cpp +++ b/cpp/obs/test/moq-source-test.cpp @@ -585,8 +585,10 @@ class Publisher { uint64_t start_us = 1'000'000) : timestamp_us(start_us) { - auto publication = TestOk(relay.origin->create_broadcast(path), "create_broadcast"); - broadcast = publication->broadcast(); + moq::RouteOptions hidden; + hidden.hidden = true; + route = TestOk(relay.origin->route(path, hidden), "route"); + broadcast = TestOk(route->broadcast(), "route broadcast"); if (video) { moq::VideoInit init{}; init.format = moq::VideoFormat::kAvc3; @@ -603,7 +605,7 @@ class Publisher { TestOk(moq::MediaTrackProducer::audio(broadcast, moq::MediaTarget::kNamed{}, init), "audio producer"); } - TestOk(publication->announce(moq::AnnounceRoute{}), "announce"); + TestOk(route->announce(), "announce"); } ~Publisher() { (void)broadcast->close(); } @@ -632,6 +634,8 @@ class Publisher { timeout); } + // Keeps the broadcast announced for the publisher's lifetime. + std::shared_ptr route; std::shared_ptr broadcast; std::shared_ptr video; std::shared_ptr audio; @@ -1187,24 +1191,12 @@ int main() std::vector{"f-stereo", "d-mono", "e-slow", "c-low", "b-unknown-bitrate", "a-disabled"})); - // Only enabled renditions are candidates while any is, even one the source holds. - const auto all = [](const auto &) { - return true; - }; - const auto none = [](const auto &) { - return false; - }; - CHECK((names(moq_source_candidates(moq_source_ranked_audio(audios), all)) == + // Only enabled renditions are candidates. + CHECK((names(moq_source_candidates(moq_source_ranked_audio(audios))) == std::vector{"f-stereo", "d-mono", "e-slow", "c-low", "b-unknown-bitrate"})); - // With every rendition disabled, only a held one remains, and nothing when none is held. for (auto &entry : audios) entry.second.enabled = false; - const auto held = [](const auto &entry) { - return entry.first == "c-low"; - }; - CHECK((names(moq_source_candidates(moq_source_ranked_audio(audios), held)) == - std::vector{"c-low"})); - CHECK(moq_source_candidates(moq_source_ranked_audio(audios), none).empty()); + CHECK(moq_source_candidates(moq_source_ranked_audio(audios)).empty()); } report("renditions rank like hang"); @@ -1308,8 +1300,8 @@ int main() } report("disabled and undecodable renditions fall through"); - // With every rendition disabled, the source holds the one it plays; with none of them - // held, the section is handled like an absent one. + // With every rendition disabled, the section is handled like an absent one, even the + // rendition the source plays. { TestRelay relay; reset(relay.Url()); @@ -1318,29 +1310,10 @@ int main() CHECK(publisher.WriteUntil([] { return g_output_frames > 0 && g_output_audio > 0; })); auto snapshot = CatalogSnapshot(publisher); auto *ctx = static_cast(source); - std::weak_ptr video; - std::weak_ptr audio; for (auto &entry : snapshot.video) entry.second.enabled = false; for (auto &entry : snapshot.audio) entry.second.enabled = false; - { - std::lock_guard lock(ctx->mutex); - const auto &conn = ctx->connection; - video = conn->video; - audio = conn->audio; - moq_source_subscribe_video(ctx, conn, snapshot); - moq_source_subscribe_audio(ctx, conn, snapshot); - CHECK(conn->video && conn->video == video.lock() && !conn->pending_video); - CHECK(conn->audio && conn->audio == audio.lock() && !conn->pending_audio); - } - const int frames = g_output_frames; - CHECK(publisher.WriteUntil([&] { return g_output_frames >= frames + 3; })); - - // A disabled rendition the source does not play is not held. - auto &config = snapshot.video.begin()->second; - config.coded = moq::Dimensions{(config.coded ? config.coded->width : 0) + 16, 16}; - snapshot.audio.begin()->second.sample_rate += 1; const int blanks = g_blank_calls; { std::lock_guard lock(ctx->mutex); @@ -1353,7 +1326,7 @@ int main() CHECK(g_blank_calls > blanks); destroySource(source); } - report("all-disabled section holds the playing rendition"); + report("an all-disabled section clears the playing rendition"); // A rendition's stream instance is part of what names it, for the active and the pending // track alike: a sibling publisher restarting behind a stable catalog resubscribes. @@ -1607,9 +1580,13 @@ int main() auto snapshot = TestOk(catalogs->next().get(), "catalog snapshot"); CHECK(snapshot.has_value()); auto config = snapshot->video.begin()->second; - std::shared_ptr hidden; + std::shared_ptr hidden; if (sibling) { - hidden = TestOk(relay.origin->create_broadcast("obs/hidden"), "unannounced sibling"); + // A different stream than the one the rendition pins, hidden until announced. + moq::RouteOptions options; + options.hidden = true; + options.instance = "obs-test-other"; + hidden = TestOk(relay.origin->route("obs/hidden", options), "unannounced sibling"); config.broadcast = "./hidden"; config.instance = "obs-test-pinned"; } @@ -1632,9 +1609,7 @@ int main() const int frames = g_output_frames; CHECK(publisher.WriteUntil([&] { return g_output_frames >= frames + 3; })); // A different stream is a real resolution failure. - moq::AnnounceRoute route; - route.instance = "obs-test-other"; - TestOk(hidden->announce(route), "announce mismatched sibling"); + TestOk(hidden->announce(), "announce mismatched sibling"); } CHECK(WaitFor([] { std::lock_guard lock(g_candidate_mutex); diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index f2edcc86509..1a37f827737 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +package:moq/socket.dart now exposes TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`GroupProducer.ready(size)` is a new `Future` that completes once a frame of `size` bytes fits a window ahead of the fastest reader. Await it before each `writeFrame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following.available()` returns null once the path goes offline `await following.available()` returns `BroadcastConsumer?`. @@ -26,6 +32,17 @@ while (true) { } ``` +### Publishing goes through one `MoqRoute` handle + +`Moq.createBroadcast(path)`, `Server.createBroadcast(path)`, and `OriginProducer.createBroadcast(path: ...)` are `route(path, options: ...)` (`route(path: ..., options: RouteOptions())` on the generated `OriginProducer`), and `OriginProducer.requests(prefix: ...)` is `routePrefix(prefix: ..., options: RouteOptions())`. Each returns a `MoqRoute`, which replaces `Publication`, `OriginRequests`, and `BroadcastPrepared`: + +- `Publication.announce(route: AnnounceRoute(...))` is `announce()`, and re-pricing is `setCost(cost: ...)`; `unannounce()` is `withdraw()`. +- `OriginRequests.requestedBroadcast()` is `requested()`, which returns `null` once the origin closes. +- `BroadcastRequest.prepare()` then `publish()` is `BroadcastRequest.route(options: ...)`: create it hidden to fill it first, then `announce()`. +- `instance()` is the stream's instance, kept across `withdraw()` and `announce()`. + +`AnnounceRoute` is `RouteOptions(hidden:, cost:, instance:, epoch:)`, given at creation. A route is visible at once unless `hidden: true`; pass it and call `announce()` after filling the catalog to keep the old order. Hold the route while the broadcast should stay announced: cancelling or disposing it retracts it, and closing its broadcast ends both. `MoqRoute`, the facts on `Announce.route`, is `RouteInfo` (`MoqRouteInfo`), and the route handle keeps the `Moq` prefix because Flutter owns `Route`. + ### Catalog renditions carry their warmup ([#7864](https://github.com/moq-dev/moq/pull/7864)) `Video` and `Audio` gain `warmup`, the catalog's decoder warmup; it defaults to `null`, so existing constructors still compile. `TrackConsumer` and the audio and video consumers read it from the rendition and start that much earlier. diff --git a/dart/moq/README.md b/dart/moq/README.md index c6ccb9272be..d33323de046 100644 --- a/dart/moq/README.md +++ b/dart/moq/README.md @@ -33,12 +33,12 @@ final server = await Server.listen( tlsGenerate: ['localhost'], ), ); -final publication = server.createBroadcast('live'); -publication.announce(route: AnnounceRoute()); // unannounced broadcasts are invisible +final route = server.route('live'); // hold it: disposing the route retracts it await for (final request in server.requests()) { final session = await request.accept(); print(session.state().connects); } +route.dispose(); // retracts the route ``` The package uses `Future` for asynchronous operations and `Stream` for diff --git a/dart/moq/bench/ffi.dart b/dart/moq/bench/ffi.dart index a4defdca85c..e1626e44531 100644 --- a/dart/moq/bench/ffi.dart +++ b/dart/moq/bench/ffi.dart @@ -41,8 +41,11 @@ Future throughput( final origin = OriginProducer( config: OriginConfig(cacheCapacityBytes: 64 << 20), ); - final publication = origin.createBroadcast(path: 'bench'); - final broadcast = publication.broadcast(); + final route = origin.route( + path: 'bench', + options: RouteOptions(hidden: true), + ); + final broadcast = route.broadcast(); final tracks = List.generate( c.publishers, (p) => @@ -124,7 +127,7 @@ Future throughput( track.dispose(); } broadcast.dispose(); - publication.dispose(); + route.dispose(); origin.dispose(); } } diff --git a/dart/moq/lib/socket.dart b/dart/moq/lib/socket.dart new file mode 100644 index 00000000000..b815734f4da --- /dev/null +++ b/dart/moq/lib/socket.dart @@ -0,0 +1,19 @@ +/// Point-to-point socket endpoints and sessions. +library; + +import 'package:moq_ffi/moq_ffi.dart'; + +/// Announces one hidden endpoint and dials or accepts sessions. +typedef Endpoint = MoqSocketEndpoint; + +/// An incoming TCP dial, consumed by accepting or rejecting it. +typedef IncomingTcp = MoqSocketIncomingTcp; + +/// An incoming track dial, consumed by accepting or rejecting it. +typedef IncomingTrack = MoqSocketIncomingTrack; + +/// A TCP session with group producer and consumer halves. +typedef TcpStream = MoqSocketTcpStream; + +/// A track session with producer and reliably subscribed consumer halves. +typedef TrackStream = MoqSocketTrackStream; diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 478853db830..e456a825512 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -13,7 +13,8 @@ import 'package:moq_ffi/moq_ffi.dart'; // // * `MoqServer` - `Server` is the listen facade in server.dart. // * `MoqContainer` / `MoqRoute` - `Container` and `Route` are Flutter's, and -// an ambiguous import would break every Flutter app that uses them. +// an ambiguous import would break every Flutter app that uses them. Its +// options and facts are still [RouteOptions] and [RouteInfo]. // * `MoqException` - `Exception` is in `dart:core`. /// A MoQ client: configure the TLS/bind knobs, then connect to a relay. @@ -37,7 +38,7 @@ typedef CongestionControl = MoqCongestionControl; /// Certificate trust and the mTLS identity for `ConnectOptions.tls`. typedef ClientTls = MoqClientTls; -/// The publish side of an origin: create broadcasts so subscribers can discover them. +/// The publish side of an origin: create routes so subscribers can discover their broadcasts. typedef OriginProducer = MoqOriginProducer; /// Config for creating an origin, such as its total cache budget. @@ -46,17 +47,11 @@ typedef OriginConfig = MoqOriginConfig; /// The subscribe side of an origin: discover and request published broadcasts. typedef OriginConsumer = MoqOriginConsumer; -/// A served route: advertises a path prefix and yields broadcast requests beneath it. -typedef OriginRequests = MoqOriginRequests; - -/// A requested broadcast not yet accepted: fulfill it with a producer or reject it. +/// A requested broadcast not yet answered: serve a held broadcast, route a new one, or reject it. typedef BroadcastRequest = MoqBroadcastRequest; -/// A claim answer being filled before its exact route is announced. -typedef BroadcastPrepared = MoqBroadcastPrepared; - -/// A broadcast published on an origin: the broadcast to fill and its announcement. -typedef Publication = MoqPublication; +/// How a route advertises itself: hidden until announced, its static cost, and a stable instance. +typedef RouteOptions = MoqRouteOptions; /// A stream of announce events under a prefix. typedef AnnounceConsumer = MoqAnnounceConsumer; @@ -67,8 +62,8 @@ typedef AnnounceConfig = MoqAnnounceConfig; /// Follows a specific broadcast path across the publisher instances that serve it. typedef Following = MoqFollowing; -/// How a broadcast advertises its exact path: its static production cost and stream instance. -typedef AnnounceRoute = MoqAnnounceRoute; +/// The read-only facts on a received route: hops, a static cost, and the stream's instance. +typedef RouteInfo = MoqRouteInfo; /// A route over a prefix: its origin-relative path, required wildcard captures (empty without wildcards), and route metadata. typedef Announce = MoqAnnounce; diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 18e7964173a..4bcaebbca9f 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -213,13 +213,14 @@ final class Moq { } } - /// Create an unannounced broadcast at [path], invisible to everyone until announced. + /// Create a route at exactly [path], owning a new broadcast to fill. /// - /// Populate its `broadcast`, calling `requests` first if tracks are served - /// on demand, then advertise it with `announce`. Disposing the publication - /// keeps the route; closing the broadcast ends it. - Publication createBroadcast(String path) => - session.publish().createBroadcast(path: path); + /// Visible at once unless [options] is hidden: create it hidden to populate + /// its `broadcast()` first (calling `requests` if tracks are served on + /// demand), then `announce()` it. Hold the route: cancelling or disposing it + /// retracts it, and closing the broadcast ends both. + MoqRoute route(String path, {RouteOptions? options}) => + session.publish().route(path: path, options: options ?? RouteOptions()); /// List the paths matching [options] as each becomes present or absent; /// prefixes stay relative to the origin. diff --git a/dart/moq/lib/src/server.dart b/dart/moq/lib/src/server.dart index 1a127b4e4aa..81acee86a89 100644 --- a/dart/moq/lib/src/server.dart +++ b/dart/moq/lib/src/server.dart @@ -53,7 +53,7 @@ final class ListenOptions { /// A listening MoQ server with publishing and subscription conveniences. /// -/// Build one with [Server.listen]. Broadcasts created via [createBroadcast] are +/// Build one with [Server.listen]. Broadcasts of routes created via [route] are /// served to incoming sessions, and [requests] streams each incoming [Request] /// for the caller to accept or reject. [close] stops accepting new sessions and /// releases the listening socket before it returns; in-flight sessions stay @@ -111,15 +111,16 @@ final class Server { } } - /// Create a broadcast at [path], served to incoming sessions. + /// Create a route at exactly [path], owning a broadcast served to incoming sessions. /// - /// Advertise it with `announce` after populating its `broadcast`. Throws - /// when [listen] was given a [ListenOptions.consume] origin but no - /// [ListenOptions.publish] one, since there is then nothing to serve from. - Publication createBroadcast(String path) { + /// Visible at once unless [options] is hidden; hold the route, since + /// cancelling or disposing it retracts it. Throws when [listen] was given a + /// [ListenOptions.consume] origin but no [ListenOptions.publish] one, since + /// there is then nothing to serve from. + MoqRoute route(String path, {RouteOptions? options}) { final origin = _publishOrigin; if (origin == null) throw StateError('no publish origin configured'); - return origin.createBroadcast(path: path); + return origin.route(path: path, options: options ?? RouteOptions()); } /// SHA-256 fingerprints of the configured TLS certificates, hex-encoded. diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index ffcc9daf1c8..0e6d525cf5b 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -281,12 +281,15 @@ void main() { final announcement = client.announced().updates().firstWhere( (event) => event is AnnounceEventPresent, ); - final publication = relay.createBroadcast(path: 'live'); - final track = publication.broadcast().publishTrack( + final route = relay.route( + path: 'live', + options: RouteOptions(hidden: true), + ); + final track = route.broadcast().publishTrack( name: 'events', info: TrackInfo(timescale: 1000000), ); - publication.announce(route: AnnounceRoute()); + route.announce(); final announced = (await announcement.timeout(timeout) as AnnounceEventPresent).announce; expect(announced.prefix, 'live'); @@ -370,10 +373,10 @@ void main() { final announcement = client.announced().updates().firstWhere( (event) => event is AnnounceEventPresent, ); - final publication = server.createBroadcast('live'); - final broadcast = publication.broadcast(); + final route = server.route('live', options: RouteOptions(hidden: true)); + final broadcast = route.broadcast(); final track = broadcast.publishTrack(name: 'events', info: null); - publication.announce(route: AnnounceRoute()); + route.announce(); final announced = (await announcement.timeout(timeout) as AnnounceEventPresent).announce; expect(announced.prefix, 'live'); @@ -383,6 +386,7 @@ void main() { track.finish(); broadcast.close(); broadcast.close(); // a second close is a no-op + route.dispose(); server.close(); }); @@ -526,8 +530,11 @@ void main() { test('a broadcast is reachable only while announced', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); - final publication = origin.createBroadcast(path: 'live'); - final broadcast = publication.broadcast(); + final route = origin.route( + path: 'live', + options: RouteOptions(hidden: true), + ); + final broadcast = route.broadcast(); broadcast.publishTrack(name: 'events', info: null); final consumer = origin.consume(); await expectLater( @@ -535,13 +542,14 @@ void main() { throwsA(anything), ); - publication.announce(route: AnnounceRoute()); + route.announce(); final announced = consumer.announced(config: MoqAnnounceConfig()); final first = await nextRoute(announced); expect(first, isA()); expect((first as AnnounceEventPresent).announce.prefix, 'live'); + expect(first.announce.route.instance, route.instanceId()); - publication.unannounce(); + route.withdraw(); final retracted = await nextRoute(announced); expect(retracted, isA()); expect((retracted as AnnounceEventAbsent).announce.prefix, 'live'); @@ -550,60 +558,90 @@ void main() { throwsA(anything), ); - publication.announce(route: AnnounceRoute()); - expect(await nextRoute(announced), isA()); + // Announcing again continues the same stream. + route.announce(); + final again = await nextRoute(announced) as AnnounceEventPresent; + expect(again.announce.route.instance, route.instanceId()); await consumer.request(path: 'live').timeout(timeout); - // Disposing the publication keeps the route; closing the broadcast ends it. - publication.dispose(); + // Disposing the broadcast handle keeps the route; cancelling the route retracts it, + // and its broadcast lives on. + broadcast.dispose(); await consumer.request(path: 'live').timeout(timeout); - broadcast.close(); + final kept = route.broadcast(); + route.cancel(); + expect(await nextRoute(announced), isA()); + expect(() => route.announce(), throwsA(isA())); + kept.publishTrack(name: 'after', info: null); + kept.close(); + route.dispose(); + announced.cancel(); + announced.dispose(); + }); + + test('closing the broadcast retracts its route', () async { + final origin = MoqOriginProducer(config: MoqOriginConfig()); + final announced = origin.consume().announced(config: MoqAnnounceConfig()); + final route = origin.route(path: 'live', options: RouteOptions()); + expect(await nextRoute(announced), isA()); + route.broadcast().close(); expect(await nextRoute(announced), isA()); + expect(() => route.announce(), throwsA(isA())); + route.dispose(); announced.cancel(); announced.dispose(); }); - test('a stale publication leaves its replacement alone', () async { + test('a closed route leaves its replacement alone', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); - final stale = origin.createBroadcast(path: 'live'); - stale.announce(route: AnnounceRoute()); + final stale = origin.route(path: 'live', options: RouteOptions()); stale.broadcast().close(); - final replacement = origin.createBroadcast(path: 'live'); - replacement.announce(route: AnnounceRoute()); - expect( - () => stale.announce(route: AnnounceRoute()), - throwsA(isA()), - ); - expect(() => stale.unannounce(), throwsA(isA())); + final replacement = origin.route(path: 'live', options: RouteOptions()); + expect(() => stale.announce(), throwsA(isA())); + expect(() => stale.setCost(cost: 1), throwsA(isA())); + stale.withdraw(); await origin.consume().request(path: 'live').timeout(timeout); replacement.broadcast().close(); + stale.dispose(); + replacement.dispose(); }); - test('a new instance restarts followers but stays listed', () async { + test('a new route restarts followers but stays listed', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); final consumer = origin.consume(); final announced = consumer.announced(config: MoqAnnounceConfig()); final following = consumer.follow(path: 'instance'); - final publication = origin.createBroadcast(path: 'instance'); + final first = origin.route(path: 'instance', options: RouteOptions()); - publication.announce(route: AnnounceRoute()); final present = await nextRoute(announced) as AnnounceEventPresent; - expect(present.announce.route.instance, isNotEmpty); + expect(present.announce.route.instance, first.instanceId()); (await following.available().timeout(timeout))!.dispose(); - publication.announce(route: AnnounceRoute(instance: 'camera', epoch: 1)); + final second = origin.route( + path: 'instance', + options: RouteOptions(instance: 'camera', epoch: 1), + ); + expect(second.instanceId(), 'camera'); (await following.available().timeout(timeout))!.dispose(); // The takeover was not listed, so the next event is the path going absent. - publication.unannounce(); + first.withdraw(); + second.withdraw(); final absent = await nextRoute(announced) as AnnounceEventAbsent; expect(absent.announce.route.instance, 'camera'); expect( - () => publication.announce(route: AnnounceRoute(instance: '')), + () => origin.route( + path: 'refused', + options: RouteOptions(instance: ''), + ), throwsA(isA()), ); + first.cancel(); + second.cancel(); + first.dispose(); + second.dispose(); following.cancel(); following.dispose(); announced.cancel(); @@ -615,17 +653,17 @@ void main() { () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); final following = origin.consume().follow(path: 'offline'); - final publication = origin.createBroadcast(path: 'offline'); - - publication.announce(route: AnnounceRoute()); + final route = origin.route(path: 'offline', options: RouteOptions()); (await following.available().timeout(timeout))!.dispose(); - publication.unannounce(); + route.withdraw(); expect(await following.available().timeout(timeout), isNull); - publication.announce(route: AnnounceRoute()); + route.announce(); (await following.available().timeout(timeout))!.dispose(); + route.cancel(); + route.dispose(); following.cancel(); following.dispose(); }, @@ -636,27 +674,37 @@ void main() { final announced = origin.consume().announced( config: MoqAnnounceConfig(prefix: 'room', filter: '*/chat'), ); - final publication = origin.createBroadcast(path: 'room/alice/chat'); - publication.announce(route: AnnounceRoute()); + final route = origin.route( + path: 'room/alice/chat', + options: RouteOptions(), + ); final update = await nextRoute(announced) as AnnounceEventPresent; expect(update.announce.prefix, 'room/alice/chat'); expect(update.announce.captures, ['alice']); + route.dispose(); }); test('dynamic serves a request under a prefix', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); - final dynamic = origin.requests(prefix: 'live'); + final dynamic = origin.routePrefix(prefix: 'live', options: RouteOptions()); final pending = origin.consume().request(path: 'live/cam'); - final request = await dynamic.requestedBroadcast().timeout(timeout); + final request = (await dynamic.requested().timeout(timeout))!; expect(request.path(), 'live/cam'); - final prepared = request.prepare(); - final served = prepared.publish(); + // A hidden job route answers nobody until it announces. + final served = request.route(options: RouteOptions(hidden: true)); + expect(served.instanceId(), isNot(dynamic.instanceId())); + served.announce(); await pending.timeout(timeout); - // The publication controls the prepared fill: closing that ends its announcement. - prepared.broadcast().close(); - expect(() => served.unannounce(), throwsA(isA())); + // Closing the job's broadcast ends its route. + served.broadcast().close(); + expect(() => served.announce(), throwsA(isA())); + expect(() => dynamic.broadcast(), throwsA(isA())); dynamic.cancel(); + await expectLater( + dynamic.requested(), + throwsA(isA()), + ); dynamic.dispose(); served.dispose(); }); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 0d4c40af414..e157887146f 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -2813,7 +2813,7 @@ class FfiConverterMoqVideoProperties { class MoqAnnounce { final String prefix; final List captures; - final MoqRoute route; + final MoqRouteInfo route; MoqAnnounce({ required this.prefix, required this.captures, @@ -2838,7 +2838,7 @@ class FfiConverterMoqAnnounce { ); final captures = captures_lifted.value; new_offset += captures_lifted.bytesRead; - final route_lifted = FfiConverterMoqRoute.read( + final route_lifted = FfiConverterMoqRouteInfo.read( Uint8List.view(buf.buffer, new_offset), ); final route = route_lifted.value; @@ -2853,7 +2853,7 @@ class FfiConverterMoqAnnounce { final total_length = FfiConverterString.allocationSize(value.prefix) + FfiConverterSequenceString.allocationSize(value.captures) + - FfiConverterMoqRoute.allocationSize(value.route) + + FfiConverterMoqRouteInfo.allocationSize(value.route) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -2870,7 +2870,7 @@ class FfiConverterMoqAnnounce { value.captures, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterMoqRoute.write( + new_offset += FfiConverterMoqRouteInfo.write( value.route, Uint8List.view(buf.buffer, new_offset), ); @@ -2880,7 +2880,7 @@ class FfiConverterMoqAnnounce { static int allocationSize(MoqAnnounce value) { return FfiConverterString.allocationSize(value.prefix) + FfiConverterSequenceString.allocationSize(value.captures) + - FfiConverterMoqRoute.allocationSize(value.route) + + FfiConverterMoqRouteInfo.allocationSize(value.route) + 0; } } @@ -2960,77 +2960,6 @@ class FfiConverterMoqAnnounceConfig { } } -class MoqAnnounceRoute { - final int cost; - final String? instance; - final int epoch; - MoqAnnounceRoute({this.cost = 0, this.instance = null, this.epoch = 0}); -} - -class FfiConverterMoqAnnounceRoute { - static MoqAnnounceRoute lift(RustBuffer buf) { - return FfiConverterMoqAnnounceRoute.read(buf.asUint8List()).value; - } - - static LiftRetVal read(Uint8List buf) { - int new_offset = buf.offsetInBytes; - final cost_lifted = FfiConverterUInt64.read( - Uint8List.view(buf.buffer, new_offset), - ); - final cost = cost_lifted.value; - new_offset += cost_lifted.bytesRead; - final instance_lifted = FfiConverterOptionalString.read( - Uint8List.view(buf.buffer, new_offset), - ); - final instance = instance_lifted.value; - new_offset += instance_lifted.bytesRead; - final epoch_lifted = FfiConverterUInt64.read( - Uint8List.view(buf.buffer, new_offset), - ); - final epoch = epoch_lifted.value; - new_offset += epoch_lifted.bytesRead; - return LiftRetVal( - MoqAnnounceRoute(cost: cost, instance: instance, epoch: epoch), - new_offset - buf.offsetInBytes, - ); - } - - static RustBuffer lower(MoqAnnounceRoute value) { - final total_length = - FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterOptionalString.allocationSize(value.instance) + - FfiConverterUInt64.allocationSize(value.epoch) + - 0; - final buf = Uint8List(total_length); - write(value, buf); - return toRustBuffer(buf); - } - - static int write(MoqAnnounceRoute value, Uint8List buf) { - int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt64.write( - value.cost, - Uint8List.view(buf.buffer, new_offset), - ); - new_offset += FfiConverterOptionalString.write( - value.instance, - Uint8List.view(buf.buffer, new_offset), - ); - new_offset += FfiConverterUInt64.write( - value.epoch, - Uint8List.view(buf.buffer, new_offset), - ); - return new_offset - buf.offsetInBytes; - } - - static int allocationSize(MoqAnnounceRoute value) { - return FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterOptionalString.allocationSize(value.instance) + - FfiConverterUInt64.allocationSize(value.epoch) + - 0; - } -} - class MoqOriginConfig { final int? cacheCapacityBytes; MoqOriginConfig({this.cacheCapacityBytes = null}); @@ -3077,12 +3006,12 @@ class FfiConverterMoqOriginConfig { } } -class MoqRoute { +class MoqRouteInfo { final List hops; final int cost; final bool anonymous; final String instance; - MoqRoute({ + MoqRouteInfo({ required this.hops, required this.cost, required this.anonymous, @@ -3090,12 +3019,12 @@ class MoqRoute { }); } -class FfiConverterMoqRoute { - static MoqRoute lift(RustBuffer buf) { - return FfiConverterMoqRoute.read(buf.asUint8List()).value; +class FfiConverterMoqRouteInfo { + static MoqRouteInfo lift(RustBuffer buf) { + return FfiConverterMoqRouteInfo.read(buf.asUint8List()).value; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; final hops_lifted = FfiConverterSequenceUInt64.read( Uint8List.view(buf.buffer, new_offset), @@ -3118,7 +3047,7 @@ class FfiConverterMoqRoute { final instance = instance_lifted.value; new_offset += instance_lifted.bytesRead; return LiftRetVal( - MoqRoute( + MoqRouteInfo( hops: hops, cost: cost, anonymous: anonymous, @@ -3128,7 +3057,7 @@ class FfiConverterMoqRoute { ); } - static RustBuffer lower(MoqRoute value) { + static RustBuffer lower(MoqRouteInfo value) { final total_length = FfiConverterSequenceUInt64.allocationSize(value.hops) + FfiConverterUInt64.allocationSize(value.cost) + @@ -3140,7 +3069,7 @@ class FfiConverterMoqRoute { return toRustBuffer(buf); } - static int write(MoqRoute value, Uint8List buf) { + static int write(MoqRouteInfo value, Uint8List buf) { int new_offset = buf.offsetInBytes; new_offset += FfiConverterSequenceUInt64.write( value.hops, @@ -3161,7 +3090,7 @@ class FfiConverterMoqRoute { return new_offset - buf.offsetInBytes; } - static int allocationSize(MoqRoute value) { + static int allocationSize(MoqRouteInfo value) { return FfiConverterSequenceUInt64.allocationSize(value.hops) + FfiConverterUInt64.allocationSize(value.cost) + FfiConverterBool.allocationSize(value.anonymous) + @@ -3170,6 +3099,99 @@ class FfiConverterMoqRoute { } } +class MoqRouteOptions { + final bool hidden; + final int cost; + final String? instance; + final int epoch; + MoqRouteOptions({ + this.hidden = false, + this.cost = 0, + this.instance = null, + this.epoch = 0, + }); +} + +class FfiConverterMoqRouteOptions { + static MoqRouteOptions lift(RustBuffer buf) { + return FfiConverterMoqRouteOptions.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final hidden_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final hidden = hidden_lifted.value; + new_offset += hidden_lifted.bytesRead; + final cost_lifted = FfiConverterUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final cost = cost_lifted.value; + new_offset += cost_lifted.bytesRead; + final instance_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final instance = instance_lifted.value; + new_offset += instance_lifted.bytesRead; + final epoch_lifted = FfiConverterUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final epoch = epoch_lifted.value; + new_offset += epoch_lifted.bytesRead; + return LiftRetVal( + MoqRouteOptions( + hidden: hidden, + cost: cost, + instance: instance, + epoch: epoch, + ), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqRouteOptions value) { + final total_length = + FfiConverterBool.allocationSize(value.hidden) + + FfiConverterUInt64.allocationSize(value.cost) + + FfiConverterOptionalString.allocationSize(value.instance) + + FfiConverterUInt64.allocationSize(value.epoch) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqRouteOptions value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterBool.write( + value.hidden, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterUInt64.write( + value.cost, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalString.write( + value.instance, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterUInt64.write( + value.epoch, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqRouteOptions value) { + return FfiConverterBool.allocationSize(value.hidden) + + FfiConverterUInt64.allocationSize(value.cost) + + FfiConverterOptionalString.allocationSize(value.instance) + + FfiConverterUInt64.allocationSize(value.epoch) + + 0; + } +} + class MoqPosition { final int group; final int frame; @@ -4138,6 +4160,7 @@ class MoqConnectionStats { final int? packetsReceived; final int? packetsLost; final String? version; + final int? datagramsUnrouted; MoqConnectionStats({ this.rtt, this.estimatedSendRateBps, @@ -4148,6 +4171,7 @@ class MoqConnectionStats { this.packetsReceived, this.packetsLost, this.version = null, + this.datagramsUnrouted = null, }); } @@ -4203,6 +4227,11 @@ class FfiConverterMoqConnectionStats { ); final version = version_lifted.value; new_offset += version_lifted.bytesRead; + final datagramsUnrouted_lifted = FfiConverterOptionalUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final datagramsUnrouted = datagramsUnrouted_lifted.value; + new_offset += datagramsUnrouted_lifted.bytesRead; return LiftRetVal( MoqConnectionStats( rtt: rtt, @@ -4214,6 +4243,7 @@ class FfiConverterMoqConnectionStats { packetsReceived: packetsReceived, packetsLost: packetsLost, version: version, + datagramsUnrouted: datagramsUnrouted, ), new_offset - buf.offsetInBytes, ); @@ -4230,6 +4260,7 @@ class FfiConverterMoqConnectionStats { FfiConverterOptionalUInt64.allocationSize(value.packetsReceived) + FfiConverterOptionalUInt64.allocationSize(value.packetsLost) + FfiConverterOptionalString.allocationSize(value.version) + + FfiConverterOptionalUInt64.allocationSize(value.datagramsUnrouted) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -4274,6 +4305,10 @@ class FfiConverterMoqConnectionStats { value.version, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalUInt64.write( + value.datagramsUnrouted, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } @@ -4287,6 +4322,7 @@ class FfiConverterMoqConnectionStats { FfiConverterOptionalUInt64.allocationSize(value.packetsReceived) + FfiConverterOptionalUInt64.allocationSize(value.packetsLost) + FfiConverterOptionalString.allocationSize(value.version) + + FfiConverterOptionalUInt64.allocationSize(value.datagramsUnrouted) + 0; } } @@ -13225,103 +13261,10 @@ class FfiConverterMoqAnnounceConsumer { } } -abstract class MoqBroadcastPreparedInterface { - MoqBroadcastProducer broadcast(); - MoqPublication publish(); -} - -final _MoqBroadcastPreparedFinalizer = Finalizer>((ptr) { - rustCall( - (status) => uniffi_moq_ffi_fn_free_moqbroadcastprepared(ptr, status), - ); -}); - -class MoqBroadcastPrepared implements MoqBroadcastPreparedInterface { - late final Pointer _ptr; - bool _isDisposed = false; - MoqBroadcastPrepared._(this._ptr) { - _MoqBroadcastPreparedFinalizer.attach(this, _ptr, detach: this); - } - factory MoqBroadcastPrepared.lift(Pointer ptr) { - return MoqBroadcastPrepared._(ptr); - } - Pointer uniffiClonePointer() { - if (_isDisposed) { - throw StateError("Object has already been disposed"); - } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqbroadcastprepared(_ptr, status), - ); - } - - void dispose() { - if (_isDisposed) { - return; - } - final status = calloc(); - _isDisposed = true; - _MoqBroadcastPreparedFinalizer.detach(this); - try { - uniffi_moq_ffi_fn_free_moqbroadcastprepared(_ptr, status); - checkCallStatus(NullRustCallStatusErrorHandler(), status); - } finally { - calloc.free(status); - } - } - - MoqBroadcastProducer broadcast() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqbroadcastprepared_broadcast( - uniffiClonePointer(), - status, - ), - FfiConverterMoqBroadcastProducer.lift, - null, - ); - } - - MoqPublication publish() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqbroadcastprepared_publish( - uniffiClonePointer(), - status, - ), - FfiConverterMoqPublication.lift, - moqExceptionErrorHandler, - ); - } -} - -class FfiConverterMoqBroadcastPrepared { - static MoqBroadcastPrepared lift(Pointer ptr) { - return MoqBroadcastPrepared.lift(ptr); - } - - static Pointer lower(MoqBroadcastPrepared value) { - return value.uniffiClonePointer(); - } - - static int allocationSize(MoqBroadcastPrepared value) { - return 8; - } - - static LiftRetVal read(Uint8List buf) { - final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); - final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqBroadcastPrepared.lift(pointer), 8); - } - - static int write(MoqBroadcastPrepared value, Uint8List buf) { - final handle = lower(value); - buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); - return 8; - } -} - abstract class MoqBroadcastRequestInterface { String path(); - MoqBroadcastPrepared prepare(); void reject({required int errorCode}); + MoqRoute route({required MoqRouteOptions options}); void serve({required MoqBroadcastConsumer broadcast}); } @@ -13373,17 +13316,6 @@ class MoqBroadcastRequest implements MoqBroadcastRequestInterface { ); } - MoqBroadcastPrepared prepare() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqbroadcastrequest_prepare( - uniffiClonePointer(), - status, - ), - FfiConverterMoqBroadcastPrepared.lift, - moqExceptionErrorHandler, - ); - } - void reject({required int errorCode}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqbroadcastrequest_reject( @@ -13394,6 +13326,18 @@ class MoqBroadcastRequest implements MoqBroadcastRequestInterface { }, moqExceptionErrorHandler); } + MoqRoute route({required MoqRouteOptions options}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqbroadcastrequest_route( + uniffiClonePointer(), + FfiConverterMoqRouteOptions.lower(options), + status, + ), + FfiConverterMoqRoute.lift, + moqExceptionErrorHandler, + ); + } + void serve({required MoqBroadcastConsumer broadcast}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqbroadcastrequest_serve( @@ -13642,8 +13586,11 @@ class FfiConverterMoqOriginConsumer { abstract class MoqOriginProducerInterface { MoqOriginConsumer consume(); - MoqPublication createBroadcast({required String path}); - MoqOriginRequests requests({required String prefix}); + MoqRoute route({required String path, required MoqRouteOptions options}); + MoqRoute routePrefix({ + required String prefix, + required MoqRouteOptions options, + }); } final _MoqOriginProducerFinalizer = Finalizer>((ptr) { @@ -13704,26 +13651,31 @@ class MoqOriginProducer implements MoqOriginProducerInterface { ); } - MoqPublication createBroadcast({required String path}) { + MoqRoute route({required String path, required MoqRouteOptions options}) { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqoriginproducer_create_broadcast( + (status) => uniffi_moq_ffi_fn_method_moqoriginproducer_route( uniffiClonePointer(), FfiConverterString.lower(path), + FfiConverterMoqRouteOptions.lower(options), status, ), - FfiConverterMoqPublication.lift, + FfiConverterMoqRoute.lift, moqExceptionErrorHandler, ); } - MoqOriginRequests requests({required String prefix}) { + MoqRoute routePrefix({ + required String prefix, + required MoqRouteOptions options, + }) { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqoriginproducer_requests( + (status) => uniffi_moq_ffi_fn_method_moqoriginproducer_route_prefix( uniffiClonePointer(), FfiConverterString.lower(prefix), + FfiConverterMoqRouteOptions.lower(options), status, ), - FfiConverterMoqOriginRequests.lift, + FfiConverterMoqRoute.lift, moqExceptionErrorHandler, ); } @@ -13755,31 +13707,34 @@ class FfiConverterMoqOriginProducer { } } -abstract class MoqOriginRequestsInterface { +abstract class MoqRouteInterface { + void announce(); + MoqBroadcastProducer broadcast(); void cancel(); - UniffiRustFuture requestedBroadcast(); + String instanceId(); + UniffiRustFuture requested(); + void setCost({required int cost}); + void withdraw(); } -final _MoqOriginRequestsFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqoriginrequests(ptr, status)); +final _MoqRouteFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqroute(ptr, status)); }); -class MoqOriginRequests implements MoqOriginRequestsInterface { +class MoqRoute implements MoqRouteInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqOriginRequests._(this._ptr) { - _MoqOriginRequestsFinalizer.attach(this, _ptr, detach: this); + MoqRoute._(this._ptr) { + _MoqRouteFinalizer.attach(this, _ptr, detach: this); } - factory MoqOriginRequests.lift(Pointer ptr) { - return MoqOriginRequests._(ptr); + factory MoqRoute.lift(Pointer ptr) { + return MoqRoute._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqoriginrequests(_ptr, status), - ); + return rustCall((status) => uniffi_moq_ffi_fn_clone_moqroute(_ptr, status)); } void dispose() { @@ -13788,160 +13743,98 @@ class MoqOriginRequests implements MoqOriginRequestsInterface { } final status = calloc(); _isDisposed = true; - _MoqOriginRequestsFinalizer.detach(this); + _MoqRouteFinalizer.detach(this); try { - uniffi_moq_ffi_fn_free_moqoriginrequests(_ptr, status); + uniffi_moq_ffi_fn_free_moqroute(_ptr, status); checkCallStatus(NullRustCallStatusErrorHandler(), status); } finally { calloc.free(status); } } - void cancel() { + void announce() { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqoriginrequests_cancel( - uniffiClonePointer(), - status, - ); - }, null); + uniffi_moq_ffi_fn_method_moqroute_announce(uniffiClonePointer(), status); + }, moqExceptionErrorHandler); } - UniffiRustFuture requestedBroadcast() { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_method_moqoriginrequests_requested_broadcast( + MoqBroadcastProducer broadcast() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqroute_broadcast( uniffiClonePointer(), + status, ), - ffi_moq_ffi_rust_future_poll_u64, - ffi_moq_ffi_rust_future_complete_u64, - ffi_moq_ffi_rust_future_cancel_u64, - ffi_moq_ffi_rust_future_free_u64, - (ptr) => - FfiConverterMoqBroadcastRequest.lift(Pointer.fromAddress(ptr)), + FfiConverterMoqBroadcastProducer.lift, moqExceptionErrorHandler, ); } -} - -class FfiConverterMoqOriginRequests { - static MoqOriginRequests lift(Pointer ptr) { - return MoqOriginRequests.lift(ptr); - } - static Pointer lower(MoqOriginRequests value) { - return value.uniffiClonePointer(); - } - - static int allocationSize(MoqOriginRequests value) { - return 8; - } - - static LiftRetVal read(Uint8List buf) { - final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); - final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqOriginRequests.lift(pointer), 8); - } - - static int write(MoqOriginRequests value, Uint8List buf) { - final handle = lower(value); - buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); - return 8; - } -} - -abstract class MoqPublicationInterface { - void announce({required MoqAnnounceRoute route}); - MoqBroadcastProducer broadcast(); - void unannounce(); -} - -final _MoqPublicationFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqpublication(ptr, status)); -}); - -class MoqPublication implements MoqPublicationInterface { - late final Pointer _ptr; - bool _isDisposed = false; - MoqPublication._(this._ptr) { - _MoqPublicationFinalizer.attach(this, _ptr, detach: this); - } - factory MoqPublication.lift(Pointer ptr) { - return MoqPublication._(ptr); - } - Pointer uniffiClonePointer() { - if (_isDisposed) { - throw StateError("Object has already been disposed"); - } - return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqpublication(_ptr, status), - ); - } - - void dispose() { - if (_isDisposed) { - return; - } - final status = calloc(); - _isDisposed = true; - _MoqPublicationFinalizer.detach(this); - try { - uniffi_moq_ffi_fn_free_moqpublication(_ptr, status); - checkCallStatus(NullRustCallStatusErrorHandler(), status); - } finally { - calloc.free(status); - } - } - - void announce({required MoqAnnounceRoute route}) { + void cancel() { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqpublication_announce( - uniffiClonePointer(), - FfiConverterMoqAnnounceRoute.lower(route), - status, - ); - }, moqExceptionErrorHandler); + uniffi_moq_ffi_fn_method_moqroute_cancel(uniffiClonePointer(), status); + }, null); } - MoqBroadcastProducer broadcast() { + String instanceId() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqpublication_broadcast( + (status) => uniffi_moq_ffi_fn_method_moqroute_instance_id( uniffiClonePointer(), status, ), - FfiConverterMoqBroadcastProducer.lift, + FfiConverterString.lift, null, ); } - void unannounce() { + UniffiRustFuture requested() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqroute_requested(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_rust_buffer, + ffi_moq_ffi_rust_future_complete_rust_buffer, + ffi_moq_ffi_rust_future_cancel_rust_buffer, + ffi_moq_ffi_rust_future_free_rust_buffer, + FfiConverterOptionalMoqBroadcastRequest.lift, + moqExceptionErrorHandler, + ); + } + + void setCost({required int cost}) { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqpublication_unannounce( + uniffi_moq_ffi_fn_method_moqroute_set_cost( uniffiClonePointer(), + FfiConverterUInt64.lower(cost), status, ); }, moqExceptionErrorHandler); } + + void withdraw() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqroute_withdraw(uniffiClonePointer(), status); + }, moqExceptionErrorHandler); + } } -class FfiConverterMoqPublication { - static MoqPublication lift(Pointer ptr) { - return MoqPublication.lift(ptr); +class FfiConverterMoqRoute { + static MoqRoute lift(Pointer ptr) { + return MoqRoute.lift(ptr); } - static Pointer lower(MoqPublication value) { + static Pointer lower(MoqRoute value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqPublication value) { + static int allocationSize(MoqRoute value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqPublication.lift(pointer), 8); + return LiftRetVal(MoqRoute.lift(pointer), 8); } - static int write(MoqPublication value, Uint8List buf) { + static int write(MoqRoute value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; @@ -14423,6 +14316,7 @@ abstract class MoqGroupProducerInterface { void abort({required int errorCode}); MoqGroupConsumer consume(); void finish(); + UniffiRustFuture ready({required int size}); int sequence(); void writeFrame({required MoqFrame frame}); } @@ -14494,6 +14388,21 @@ class MoqGroupProducer implements MoqGroupProducerInterface { }, moqExceptionErrorHandler); } + UniffiRustFuture ready({required int size}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqgroupproducer_ready( + uniffiClonePointer(), + FfiConverterUInt64.lower(size), + ), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_cancel_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); + } + int sequence() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqgroupproducer_sequence( @@ -15654,52 +15563,53 @@ class FfiConverterMoqTokens { } } -abstract class MoqVideoConsumerInterface { - void cancel(); - UniffiRustFuture next(); +abstract class MoqSocketEndpointInterface { + UniffiRustFuture acceptTcp(); + UniffiRustFuture acceptTrack(); + void close(); + UniffiRustFuture dialTcp({required String peer}); + UniffiRustFuture dialTrack({ + required String peer, + required MoqTrackInfo? info, + }); + String id(); + String prefix(); } -final _MoqVideoConsumerFinalizer = Finalizer>((ptr) { - rustCall((status) => uniffi_moq_ffi_fn_free_moqvideoconsumer(ptr, status)); +final _MoqSocketEndpointFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqsocketendpoint(ptr, status)); }); -class MoqVideoConsumer implements MoqVideoConsumerInterface { +class MoqSocketEndpoint implements MoqSocketEndpointInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqVideoConsumer._(this._ptr) { - _MoqVideoConsumerFinalizer.attach(this, _ptr, detach: this); + MoqSocketEndpoint._(this._ptr) { + _MoqSocketEndpointFinalizer.attach(this, _ptr, detach: this); } - static UniffiRustFuture subscribe({ - required MoqBroadcastConsumer broadcast, + MoqSocketEndpoint({ + required MoqOriginProducer origin, + required String prefix, required String id, - required MoqVideo catalogVideo, - required MoqVideoDecoderOutput output, - }) { - return uniffiRustCallAsync( - () => uniffi_moq_ffi_fn_constructor_moqvideoconsumer_subscribe( - FfiConverterMoqBroadcastConsumer.lower(broadcast), - FfiConverterString.lower(id), - FfiConverterMoqVideo.lower(catalogVideo), - FfiConverterMoqVideoDecoderOutput.lower(output), - ), - ffi_moq_ffi_rust_future_poll_u64, - ffi_moq_ffi_rust_future_complete_u64, - ffi_moq_ffi_rust_future_cancel_u64, - ffi_moq_ffi_rust_future_free_u64, - (int handle) => MoqVideoConsumer._(Pointer.fromAddress(handle)), - moqExceptionErrorHandler, - ); + }) : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqsocketendpoint_new( + FfiConverterMoqOriginProducer.lower(origin), + FfiConverterString.lower(prefix), + FfiConverterString.lower(id), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqSocketEndpointFinalizer.attach(this, _ptr, detach: this); } - - factory MoqVideoConsumer.lift(Pointer ptr) { - return MoqVideoConsumer._(ptr); + factory MoqSocketEndpoint.lift(Pointer ptr) { + return MoqSocketEndpoint._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqvideoconsumer(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqsocketendpoint(_ptr, status), ); } @@ -15709,93 +15619,168 @@ class MoqVideoConsumer implements MoqVideoConsumerInterface { } final status = calloc(); _isDisposed = true; - _MoqVideoConsumerFinalizer.detach(this); + _MoqSocketEndpointFinalizer.detach(this); try { - uniffi_moq_ffi_fn_free_moqvideoconsumer(_ptr, status); + uniffi_moq_ffi_fn_free_moqsocketendpoint(_ptr, status); checkCallStatus(NullRustCallStatusErrorHandler(), status); } finally { calloc.free(status); } } - void cancel() { + UniffiRustFuture acceptTcp() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_tcp( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketIncomingTcp.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + UniffiRustFuture acceptTrack() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_track( + uniffiClonePointer(), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => FfiConverterMoqSocketIncomingTrack.lift( + Pointer.fromAddress(ptr), + ), + moqExceptionErrorHandler, + ); + } + + void close() { return rustCall((status) { - uniffi_moq_ffi_fn_method_moqvideoconsumer_cancel( + uniffi_moq_ffi_fn_method_moqsocketendpoint_close( uniffiClonePointer(), status, ); }, null); } - UniffiRustFuture next() { + UniffiRustFuture dialTcp({required String peer}) { return uniffiRustCallAsync( - () => - uniffi_moq_ffi_fn_method_moqvideoconsumer_next(uniffiClonePointer()), - ffi_moq_ffi_rust_future_poll_rust_buffer, - ffi_moq_ffi_rust_future_complete_rust_buffer, - ffi_moq_ffi_rust_future_cancel_rust_buffer, - ffi_moq_ffi_rust_future_free_rust_buffer, - FfiConverterOptionalMoqVideoRead.lift, + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_tcp( + uniffiClonePointer(), + FfiConverterString.lower(peer), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTcpStream.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + UniffiRustFuture dialTrack({ + required String peer, + required MoqTrackInfo? info, + }) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_track( + uniffiClonePointer(), + FfiConverterString.lower(peer), + FfiConverterOptionalMoqTrackInfo.lower(info), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTrackStream.lift(Pointer.fromAddress(ptr)), moqExceptionErrorHandler, ); } + + String id() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketendpoint_id( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + String prefix() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketendpoint_prefix( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } } -class FfiConverterMoqVideoConsumer { - static MoqVideoConsumer lift(Pointer ptr) { - return MoqVideoConsumer.lift(ptr); +class FfiConverterMoqSocketEndpoint { + static MoqSocketEndpoint lift(Pointer ptr) { + return MoqSocketEndpoint.lift(ptr); } - static Pointer lower(MoqVideoConsumer value) { + static Pointer lower(MoqSocketEndpoint value) { return value.uniffiClonePointer(); } - static int allocationSize(MoqVideoConsumer value) { + static int allocationSize(MoqSocketEndpoint value) { return 8; } - static LiftRetVal read(Uint8List buf) { + static LiftRetVal read(Uint8List buf) { final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); final pointer = Pointer.fromAddress(handle); - return LiftRetVal(MoqVideoConsumer.lift(pointer), 8); + return LiftRetVal(MoqSocketEndpoint.lift(pointer), 8); } - static int write(MoqVideoConsumer value, Uint8List buf) { + static int write(MoqSocketEndpoint value, Uint8List buf) { final handle = lower(value); buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); return 8; } } -abstract class MoqVideoDecodedFrameInterface { - int height(); - Uint8List pixels({required MoqVideoPixelFormat format}); - MoqVideoSurface? surface(); - MoqTimestamp timestamp(); - int width(); +abstract class MoqSocketIncomingTcpInterface { + UniffiRustFuture accept(); + String peer(); + void reject(); + String session(); } -final _MoqVideoDecodedFrameFinalizer = Finalizer>((ptr) { +final _MoqSocketIncomingTcpFinalizer = Finalizer>((ptr) { rustCall( - (status) => uniffi_moq_ffi_fn_free_moqvideodecodedframe(ptr, status), + (status) => uniffi_moq_ffi_fn_free_moqsocketincomingtcp(ptr, status), ); }); -class MoqVideoDecodedFrame implements MoqVideoDecodedFrameInterface { +class MoqSocketIncomingTcp implements MoqSocketIncomingTcpInterface { late final Pointer _ptr; bool _isDisposed = false; - MoqVideoDecodedFrame._(this._ptr) { - _MoqVideoDecodedFrameFinalizer.attach(this, _ptr, detach: this); + MoqSocketIncomingTcp._(this._ptr) { + _MoqSocketIncomingTcpFinalizer.attach(this, _ptr, detach: this); } - factory MoqVideoDecodedFrame.lift(Pointer ptr) { - return MoqVideoDecodedFrame._(ptr); + factory MoqSocketIncomingTcp.lift(Pointer ptr) { + return MoqSocketIncomingTcp._(ptr); } Pointer uniffiClonePointer() { if (_isDisposed) { throw StateError("Object has already been disposed"); } return rustCall( - (status) => uniffi_moq_ffi_fn_clone_moqvideodecodedframe(_ptr, status), + (status) => uniffi_moq_ffi_fn_clone_moqsocketincomingtcp(_ptr, status), ); } @@ -15805,52 +15790,637 @@ class MoqVideoDecodedFrame implements MoqVideoDecodedFrameInterface { } final status = calloc(); _isDisposed = true; - _MoqVideoDecodedFrameFinalizer.detach(this); + _MoqSocketIncomingTcpFinalizer.detach(this); try { - uniffi_moq_ffi_fn_free_moqvideodecodedframe(_ptr, status); + uniffi_moq_ffi_fn_free_moqsocketincomingtcp(_ptr, status); checkCallStatus(NullRustCallStatusErrorHandler(), status); } finally { calloc.free(status); } } - int height() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_height( + UniffiRustFuture accept() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketincomingtcp_accept( uniffiClonePointer(), - status, ), - FfiConverterUInt32.lift, - null, + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTcpStream.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, ); } - Uint8List pixels({required MoqVideoPixelFormat format}) { + String peer() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_pixels( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtcp_peer( uniffiClonePointer(), - FfiConverterMoqVideoPixelFormat.lower(format), status, ), - FfiConverterUint8List.lift, - moqExceptionErrorHandler, + FfiConverterString.lift, + null, ); } - MoqVideoSurface? surface() { - return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_surface( + void reject() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqsocketincomingtcp_reject( uniffiClonePointer(), status, - ), - FfiConverterOptionalMoqVideoSurface.lift, - null, - ); + ); + }, moqExceptionErrorHandler); } - MoqTimestamp timestamp() { + String session() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_timestamp( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtcp_session( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketIncomingTcp { + static MoqSocketIncomingTcp lift(Pointer ptr) { + return MoqSocketIncomingTcp.lift(ptr); + } + + static Pointer lower(MoqSocketIncomingTcp value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketIncomingTcp value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketIncomingTcp.lift(pointer), 8); + } + + static int write(MoqSocketIncomingTcp value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketIncomingTrackInterface { + UniffiRustFuture accept({required MoqTrackInfo? info}); + String peer(); + void reject(); + String session(); +} + +final _MoqSocketIncomingTrackFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqsocketincomingtrack(ptr, status), + ); +}); + +class MoqSocketIncomingTrack implements MoqSocketIncomingTrackInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketIncomingTrack._(this._ptr) { + _MoqSocketIncomingTrackFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketIncomingTrack.lift(Pointer ptr) { + return MoqSocketIncomingTrack._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsocketincomingtrack(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketIncomingTrackFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsocketincomingtrack(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + UniffiRustFuture accept({required MoqTrackInfo? info}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqsocketincomingtrack_accept( + uniffiClonePointer(), + FfiConverterOptionalMoqTrackInfo.lower(info), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => + FfiConverterMoqSocketTrackStream.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + String peer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtrack_peer( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + void reject() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqsocketincomingtrack_reject( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + + String session() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsocketincomingtrack_session( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketIncomingTrack { + static MoqSocketIncomingTrack lift(Pointer ptr) { + return MoqSocketIncomingTrack.lift(ptr); + } + + static Pointer lower(MoqSocketIncomingTrack value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketIncomingTrack value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketIncomingTrack.lift(pointer), 8); + } + + static int write(MoqSocketIncomingTrack value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketTcpStreamInterface { + MoqGroupConsumer consumer(); + String peer(); + MoqGroupProducer producer(); + String session(); +} + +final _MoqSocketTcpStreamFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqsockettcpstream(ptr, status)); +}); + +class MoqSocketTcpStream implements MoqSocketTcpStreamInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketTcpStream._(this._ptr) { + _MoqSocketTcpStreamFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketTcpStream.lift(Pointer ptr) { + return MoqSocketTcpStream._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsockettcpstream(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketTcpStreamFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsockettcpstream(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + MoqGroupConsumer consumer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_consumer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqGroupConsumer.lift, + null, + ); + } + + String peer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_peer( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + MoqGroupProducer producer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_producer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqGroupProducer.lift, + null, + ); + } + + String session() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettcpstream_session( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketTcpStream { + static MoqSocketTcpStream lift(Pointer ptr) { + return MoqSocketTcpStream.lift(ptr); + } + + static Pointer lower(MoqSocketTcpStream value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketTcpStream value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketTcpStream.lift(pointer), 8); + } + + static int write(MoqSocketTcpStream value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqSocketTrackStreamInterface { + MoqTrackConsumer consumer(); + String peer(); + MoqTrackProducer producer(); + String session(); +} + +final _MoqSocketTrackStreamFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqsockettrackstream(ptr, status), + ); +}); + +class MoqSocketTrackStream implements MoqSocketTrackStreamInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqSocketTrackStream._(this._ptr) { + _MoqSocketTrackStreamFinalizer.attach(this, _ptr, detach: this); + } + factory MoqSocketTrackStream.lift(Pointer ptr) { + return MoqSocketTrackStream._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqsockettrackstream(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqSocketTrackStreamFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqsockettrackstream(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + MoqTrackConsumer consumer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_consumer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqTrackConsumer.lift, + null, + ); + } + + String peer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_peer( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + MoqTrackProducer producer() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_producer( + uniffiClonePointer(), + status, + ), + FfiConverterMoqTrackProducer.lift, + null, + ); + } + + String session() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsockettrackstream_session( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqSocketTrackStream { + static MoqSocketTrackStream lift(Pointer ptr) { + return MoqSocketTrackStream.lift(ptr); + } + + static Pointer lower(MoqSocketTrackStream value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqSocketTrackStream value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqSocketTrackStream.lift(pointer), 8); + } + + static int write(MoqSocketTrackStream value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqVideoConsumerInterface { + void cancel(); + UniffiRustFuture next(); +} + +final _MoqVideoConsumerFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqvideoconsumer(ptr, status)); +}); + +class MoqVideoConsumer implements MoqVideoConsumerInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqVideoConsumer._(this._ptr) { + _MoqVideoConsumerFinalizer.attach(this, _ptr, detach: this); + } + static UniffiRustFuture subscribe({ + required MoqBroadcastConsumer broadcast, + required String id, + required MoqVideo catalogVideo, + required MoqVideoDecoderOutput output, + }) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_constructor_moqvideoconsumer_subscribe( + FfiConverterMoqBroadcastConsumer.lower(broadcast), + FfiConverterString.lower(id), + FfiConverterMoqVideo.lower(catalogVideo), + FfiConverterMoqVideoDecoderOutput.lower(output), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_cancel_u64, + ffi_moq_ffi_rust_future_free_u64, + (int handle) => MoqVideoConsumer._(Pointer.fromAddress(handle)), + moqExceptionErrorHandler, + ); + } + + factory MoqVideoConsumer.lift(Pointer ptr) { + return MoqVideoConsumer._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqvideoconsumer(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqVideoConsumerFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqvideoconsumer(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + void cancel() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqvideoconsumer_cancel( + uniffiClonePointer(), + status, + ); + }, null); + } + + UniffiRustFuture next() { + return uniffiRustCallAsync( + () => + uniffi_moq_ffi_fn_method_moqvideoconsumer_next(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_rust_buffer, + ffi_moq_ffi_rust_future_complete_rust_buffer, + ffi_moq_ffi_rust_future_cancel_rust_buffer, + ffi_moq_ffi_rust_future_free_rust_buffer, + FfiConverterOptionalMoqVideoRead.lift, + moqExceptionErrorHandler, + ); + } +} + +class FfiConverterMoqVideoConsumer { + static MoqVideoConsumer lift(Pointer ptr) { + return MoqVideoConsumer.lift(ptr); + } + + static Pointer lower(MoqVideoConsumer value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqVideoConsumer value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqVideoConsumer.lift(pointer), 8); + } + + static int write(MoqVideoConsumer value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqVideoDecodedFrameInterface { + int height(); + Uint8List pixels({required MoqVideoPixelFormat format}); + MoqVideoSurface? surface(); + MoqTimestamp timestamp(); + int width(); +} + +final _MoqVideoDecodedFrameFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqvideodecodedframe(ptr, status), + ); +}); + +class MoqVideoDecodedFrame implements MoqVideoDecodedFrameInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqVideoDecodedFrame._(this._ptr) { + _MoqVideoDecodedFrameFinalizer.attach(this, _ptr, detach: this); + } + factory MoqVideoDecodedFrame.lift(Pointer ptr) { + return MoqVideoDecodedFrame._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqvideodecodedframe(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqVideoDecodedFrameFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqvideodecodedframe(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + int height() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_height( + uniffiClonePointer(), + status, + ), + FfiConverterUInt32.lift, + null, + ); + } + + Uint8List pixels({required MoqVideoPixelFormat format}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_pixels( + uniffiClonePointer(), + FfiConverterMoqVideoPixelFormat.lower(format), + status, + ), + FfiConverterUint8List.lift, + moqExceptionErrorHandler, + ); + } + + MoqVideoSurface? surface() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_surface( + uniffiClonePointer(), + status, + ), + FfiConverterOptionalMoqVideoSurface.lift, + null, + ); + } + + MoqTimestamp timestamp() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_timestamp( uniffiClonePointer(), status, ), @@ -16958,6 +17528,55 @@ class FfiConverterOptionalMoqBroadcastConsumer { } } +class FfiConverterOptionalMoqBroadcastRequest { + static MoqBroadcastRequest? lift(RustBuffer buf) { + return FfiConverterOptionalMoqBroadcastRequest.read( + buf.asUint8List(), + ).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterMoqBroadcastRequest.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([MoqBroadcastRequest? value]) { + if (value == null) { + return 1; + } + return FfiConverterMoqBroadcastRequest.allocationSize(value) + 1; + } + + static RustBuffer lower(MoqBroadcastRequest? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List( + FfiConverterOptionalMoqBroadcastRequest.allocationSize(value), + ); + FfiConverterOptionalMoqBroadcastRequest.write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqBroadcastRequest? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterMoqBroadcastRequest.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + class FfiConverterOptionalMoqCatalog { static MoqCatalog? lift(RustBuffer buf) { return FfiConverterOptionalMoqCatalog.read(buf.asUint8List()).value; @@ -20803,38 +21422,6 @@ external Pointer uniffi_moq_ffi_fn_method_moqannounceconsumer_next( Pointer ptr, ); -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_clone_moqbroadcastprepared( - Pointer handle, - Pointer uniffiStatus, -); - -@Native, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_free_moqbroadcastprepared( - Pointer handle, - Pointer uniffiStatus, -); - -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_method_moqbroadcastprepared_broadcast( - Pointer ptr, - Pointer uniffiStatus, -); - -@Native Function(Pointer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_method_moqbroadcastprepared_publish( - Pointer ptr, - Pointer uniffiStatus, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -20859,20 +21446,21 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqbroadcastrequest_path( Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Uint16, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqbroadcastrequest_prepare( +external void uniffi_moq_ffi_fn_method_moqbroadcastrequest_reject( Pointer ptr, + int error_code, Pointer uniffiStatus, ); -@Native, Uint16, Pointer)>( - assetId: _uniffiAssetId, -) -external void uniffi_moq_ffi_fn_method_moqbroadcastrequest_reject( +@Native< + Pointer Function(Pointer, RustBuffer, Pointer) +>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqbroadcastrequest_route( Pointer ptr, - int error_code, + RustBuffer options, Pointer uniffiStatus, ); @@ -20996,28 +21584,39 @@ external Pointer uniffi_moq_ffi_fn_method_moqoriginproducer_consume( ); @Native< - Pointer Function(Pointer, RustBuffer, Pointer) + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) >(assetId: _uniffiAssetId) -external Pointer -uniffi_moq_ffi_fn_method_moqoriginproducer_create_broadcast( +external Pointer uniffi_moq_ffi_fn_method_moqoriginproducer_route( Pointer ptr, RustBuffer path, + RustBuffer options, Pointer uniffiStatus, ); @Native< - Pointer Function(Pointer, RustBuffer, Pointer) + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) >(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqoriginproducer_requests( +external Pointer uniffi_moq_ffi_fn_method_moqoriginproducer_route_prefix( Pointer ptr, RustBuffer prefix, + RustBuffer options, Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqoriginrequests( +external Pointer uniffi_moq_ffi_fn_clone_moqroute( Pointer handle, Pointer uniffiStatus, ); @@ -21025,7 +21624,7 @@ external Pointer uniffi_moq_ffi_fn_clone_moqoriginrequests( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqoriginrequests( +external void uniffi_moq_ffi_fn_free_moqroute( Pointer handle, Pointer uniffiStatus, ); @@ -21033,54 +21632,53 @@ external void uniffi_moq_ffi_fn_free_moqoriginrequests( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqoriginrequests_cancel( +external void uniffi_moq_ffi_fn_method_moqroute_announce( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer -uniffi_moq_ffi_fn_method_moqoriginrequests_requested_broadcast( - Pointer ptr, -); - @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqpublication( - Pointer handle, +external Pointer uniffi_moq_ffi_fn_method_moqroute_broadcast( + Pointer ptr, Pointer uniffiStatus, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqpublication( - Pointer handle, +external void uniffi_moq_ffi_fn_method_moqroute_cancel( + Pointer ptr, Pointer uniffiStatus, ); -@Native, RustBuffer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqpublication_announce( +external RustBuffer uniffi_moq_ffi_fn_method_moqroute_instance_id( Pointer ptr, - RustBuffer route, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqroute_requested( + Pointer ptr, +); + +@Native, Uint64, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqpublication_broadcast( +external void uniffi_moq_ffi_fn_method_moqroute_set_cost( Pointer ptr, + int cost, Pointer uniffiStatus, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqpublication_unannounce( +external void uniffi_moq_ffi_fn_method_moqroute_withdraw( Pointer ptr, Pointer uniffiStatus, ); @@ -21354,6 +21952,12 @@ external void uniffi_moq_ffi_fn_method_moqgroupproducer_finish( Pointer uniffiStatus, ); +@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqgroupproducer_ready( + Pointer ptr, + int size, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -21633,15 +22237,251 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_transport( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_url( +external RustBuffer uniffi_moq_ffi_fn_method_moqrequest_url( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqserver( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqserver( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_constructor_moqserver_new( + RustBuffer config, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqserver_accept( + Pointer ptr, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqserver_cancel( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqserver_cert_fingerprints( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqserver_listen( + Pointer ptr, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqclient( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqclient( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_constructor_moqclient_new( + RustBuffer config, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqclient_cancel( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, RustBuffer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqclient_connect( + Pointer ptr, + RustBuffer url, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqsession( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqsession( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_allocator( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Uint32, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqsession_cancel( + Pointer ptr, + int code, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsession_closed( + Pointer ptr, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_consume( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_grants( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_publish( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_recv_bandwidth( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqsession_shutdown( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_state( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsession_state_changed( + Pointer ptr, + int after, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_stats( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsession_tokens( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_viewers( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqtokens( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqtokens( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqtokens_answered( + Pointer ptr, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqtokens_set( Pointer ptr, + RustBuffer tokens, Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqserver( +external Pointer uniffi_moq_ffi_fn_clone_moqsocketendpoint( Pointer handle, Pointer uniffiStatus, ); @@ -21649,49 +22489,81 @@ external Pointer uniffi_moq_ffi_fn_clone_moqserver( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqserver( +external void uniffi_moq_ffi_fn_free_moqsocketendpoint( Pointer handle, Pointer uniffiStatus, ); -@Native Function(RustBuffer, Pointer)>( - assetId: _uniffiAssetId, -) -external Pointer uniffi_moq_ffi_fn_constructor_moqserver_new( - RustBuffer config, +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_constructor_moqsocketendpoint_new( + Pointer origin, + RustBuffer prefix, + RustBuffer id, Pointer uniffiStatus, ); @Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqserver_accept( +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_tcp( + Pointer ptr, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_accept_track( Pointer ptr, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqserver_cancel( +external void uniffi_moq_ffi_fn_method_moqsocketendpoint_close( Pointer ptr, Pointer uniffiStatus, ); +@Native Function(Pointer, RustBuffer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_tcp( + Pointer ptr, + RustBuffer peer, +); + +@Native Function(Pointer, RustBuffer, RustBuffer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsocketendpoint_dial_track( + Pointer ptr, + RustBuffer peer, + RustBuffer info, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqserver_cert_fingerprints( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketendpoint_id( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqserver_listen( +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketendpoint_prefix( Pointer ptr, + Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqclient( +external Pointer uniffi_moq_ffi_fn_clone_moqsocketincomingtcp( Pointer handle, Pointer uniffiStatus, ); @@ -21699,39 +22571,44 @@ external Pointer uniffi_moq_ffi_fn_clone_moqclient( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqclient( +external void uniffi_moq_ffi_fn_free_moqsocketincomingtcp( Pointer handle, Pointer uniffiStatus, ); -@Native Function(RustBuffer, Pointer)>( +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsocketincomingtcp_accept( + Pointer ptr, +); + +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_constructor_moqclient_new( - RustBuffer config, +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtcp_peer( + Pointer ptr, Pointer uniffiStatus, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqclient_cancel( +external void uniffi_moq_ffi_fn_method_moqsocketincomingtcp_reject( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, RustBuffer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqclient_connect( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtcp_session( Pointer ptr, - RustBuffer url, + Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqsession( +external Pointer uniffi_moq_ffi_fn_clone_moqsocketincomingtrack( Pointer handle, Pointer uniffiStatus, ); @@ -21739,45 +22616,39 @@ external Pointer uniffi_moq_ffi_fn_clone_moqsession( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqsession( +external void uniffi_moq_ffi_fn_free_moqsocketincomingtrack( Pointer handle, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native Function(Pointer, RustBuffer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_allocator( +external Pointer uniffi_moq_ffi_fn_method_moqsocketincomingtrack_accept( Pointer ptr, - Pointer uniffiStatus, + RustBuffer info, ); -@Native, Uint32, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqsession_cancel( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtrack_peer( Pointer ptr, - int code, Pointer uniffiStatus, ); -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqsession_closed( - Pointer ptr, -); - -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_consume( +external void uniffi_moq_ffi_fn_method_moqsocketincomingtrack_reject( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_grants( +external RustBuffer uniffi_moq_ffi_fn_method_moqsocketincomingtrack_session( Pointer ptr, Pointer uniffiStatus, ); @@ -21785,23 +22656,23 @@ external Pointer uniffi_moq_ffi_fn_method_moqsession_grants( @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_publish( - Pointer ptr, +external Pointer uniffi_moq_ffi_fn_clone_moqsockettcpstream( + Pointer handle, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_recv_bandwidth( - Pointer ptr, +external void uniffi_moq_ffi_fn_free_moqsockettcpstream( + Pointer handle, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqsession_shutdown( +external Pointer uniffi_moq_ffi_fn_method_moqsockettcpstream_consumer( Pointer ptr, Pointer uniffiStatus, ); @@ -21809,68 +22680,72 @@ external void uniffi_moq_ffi_fn_method_moqsession_shutdown( @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_state( +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettcpstream_peer( Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer, Uint64)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqsession_state_changed( +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsockettcpstream_producer( Pointer ptr, - int after, + Pointer uniffiStatus, ); @Native, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_stats( +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettcpstream_session( Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqsession_tokens( - Pointer ptr, +external Pointer uniffi_moq_ffi_fn_clone_moqsockettrackstream( + Pointer handle, Pointer uniffiStatus, ); -@Native Function(Pointer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_method_moqsession_viewers( - Pointer ptr, +external void uniffi_moq_ffi_fn_free_moqsockettrackstream( + Pointer handle, Pointer uniffiStatus, ); @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external Pointer uniffi_moq_ffi_fn_clone_moqtokens( - Pointer handle, +external Pointer uniffi_moq_ffi_fn_method_moqsockettrackstream_consumer( + Pointer ptr, Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_free_moqtokens( - Pointer handle, +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettrackstream_peer( + Pointer ptr, Pointer uniffiStatus, ); -@Native Function(Pointer)>(assetId: _uniffiAssetId) -external Pointer uniffi_moq_ffi_fn_method_moqtokens_answered( +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsockettrackstream_producer( Pointer ptr, + Pointer uniffiStatus, ); -@Native, RustBuffer, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external void uniffi_moq_ffi_fn_method_moqtokens_set( +external RustBuffer uniffi_moq_ffi_fn_method_moqsockettrackstream_session( Pointer ptr, - RustBuffer tokens, Pointer uniffiStatus, ); @@ -22835,20 +23710,14 @@ external int uniffi_moq_ffi_checksum_method_moqannounceconsumer_cancel(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqannounceconsumer_next(); -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqbroadcastprepared_broadcast(); - -@Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqbroadcastprepared_publish(); - @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastrequest_path(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqbroadcastrequest_prepare(); +external int uniffi_moq_ffi_checksum_method_moqbroadcastrequest_reject(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqbroadcastrequest_reject(); +external int uniffi_moq_ffi_checksum_method_moqbroadcastrequest_route(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastrequest_serve(); @@ -22872,27 +23741,31 @@ external int uniffi_moq_ffi_checksum_method_moqoriginconsumer_request(); external int uniffi_moq_ffi_checksum_method_moqoriginproducer_consume(); @Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast(); +external int uniffi_moq_ffi_checksum_method_moqoriginproducer_route(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqoriginproducer_requests(); +external int uniffi_moq_ffi_checksum_method_moqoriginproducer_route_prefix(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqoriginrequests_cancel(); +external int uniffi_moq_ffi_checksum_method_moqroute_announce(); @Native(assetId: _uniffiAssetId) -external int -uniffi_moq_ffi_checksum_method_moqoriginrequests_requested_broadcast(); +external int uniffi_moq_ffi_checksum_method_moqroute_broadcast(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqroute_cancel(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqpublication_announce(); +external int uniffi_moq_ffi_checksum_method_moqroute_instance_id(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqpublication_broadcast(); +external int uniffi_moq_ffi_checksum_method_moqroute_requested(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqpublication_unannounce(); +external int uniffi_moq_ffi_checksum_method_moqroute_set_cost(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqroute_withdraw(); @Native(assetId: _uniffiAssetId) external int @@ -22966,6 +23839,9 @@ external int uniffi_moq_ffi_checksum_method_moqgroupproducer_consume(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgroupproducer_finish(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqgroupproducer_ready(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence(); @@ -23110,6 +23986,75 @@ external int uniffi_moq_ffi_checksum_method_moqtokens_answered(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtokens_set(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_tcp(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_track(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_close(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_tcp(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_track(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_id(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketendpoint_prefix(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_accept(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_reject(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_session(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_accept(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_reject(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_session(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_consumer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_producer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettcpstream_session(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_consumer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_peer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_producer(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsockettrackstream_session(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel(); @@ -23246,6 +24191,9 @@ external int uniffi_moq_ffi_checksum_constructor_moqserver_new(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_constructor_moqclient_new(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqsocketendpoint_new(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_constructor_moqvideoconsumer_subscribe(); @@ -23648,23 +24596,16 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqannounceconsumer_next() != 38311) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastprepared_broadcast() != - 42363) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } - if (uniffi_moq_ffi_checksum_method_moqbroadcastprepared_publish() != 60136) { - throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); - } if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_path() != 6534) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_prepare() != 45877) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_reject() != 9727) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_reject() != 9727) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_route() != 16046) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_serve() != 16802) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastrequest_serve() != 2580) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqfollowing_available() != 26337) { @@ -23685,27 +24626,31 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqoriginproducer_consume() != 52357) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast() != - 43708) { + if (uniffi_moq_ffi_checksum_method_moqoriginproducer_route() != 27041) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqoriginproducer_route_prefix() != 3472) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginproducer_requests() != 8499) { + if (uniffi_moq_ffi_checksum_method_moqroute_announce() != 46977) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginrequests_cancel() != 18954) { + if (uniffi_moq_ffi_checksum_method_moqroute_broadcast() != 31588) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginrequests_requested_broadcast() != - 47029) { + if (uniffi_moq_ffi_checksum_method_moqroute_cancel() != 41411) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqpublication_announce() != 60624) { + if (uniffi_moq_ffi_checksum_method_moqroute_instance_id() != 18728) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqpublication_broadcast() != 38236) { + if (uniffi_moq_ffi_checksum_method_moqroute_requested() != 49558) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqpublication_unannounce() != 21154) { + if (uniffi_moq_ffi_checksum_method_moqroute_set_cost() != 30911) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqroute_withdraw() != 4611) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_create_datagrams() != @@ -23781,6 +24726,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqgroupproducer_finish() != 61241) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_ready() != 10687) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -23927,6 +24875,76 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtokens_set() != 20110) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_tcp() != 55983) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_accept_track() != 3641) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_close() != 33296) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_tcp() != 61959) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_dial_track() != 5972) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_id() != 2699) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketendpoint_prefix() != 7033) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_accept() != 37027) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_peer() != 44709) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_reject() != 1335) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtcp_session() != 25054) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_accept() != 43414) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_peer() != 60388) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_reject() != 3072) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsocketincomingtrack_session() != + 36889) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_consumer() != 11536) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_peer() != 13339) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_producer() != 6563) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettcpstream_session() != 61735) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_consumer() != 2651) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_peer() != 40311) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_producer() != 22232) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqsockettrackstream_session() != 30303) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel() != 21122) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -23989,7 +25007,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqaudioconsumer_subscribe() != - 42847) { + 37967) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqaudioencoder_new() != 52616) { @@ -24065,7 +25083,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_constructor_moqoriginproducer_new() != 48126) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_constructor_moqbroadcastproducer_new() != 12099) { + if (uniffi_moq_ffi_checksum_constructor_moqbroadcastproducer_new() != 2448) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqserver_new() != 49910) { @@ -24074,8 +25092,11 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_constructor_moqclient_new() != 48355) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_constructor_moqsocketendpoint_new() != 7442) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_constructor_moqvideoconsumer_subscribe() != - 59752) { + 7811) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_constructor_moqvideoencoder_new() != 22531) { diff --git a/dart/moq_ffi/test/future_test.dart b/dart/moq_ffi/test/future_test.dart index 92d8d311bdb..62e154c4ff9 100644 --- a/dart/moq_ffi/test/future_test.dart +++ b/dart/moq_ffi/test/future_test.dart @@ -137,12 +137,15 @@ void main() { await pending.cancel(); await error; final next = cursor.next(); - final publication = origin.createBroadcast(path: 'still-listening'); + final route = origin.route( + path: 'still-listening', + options: MoqRouteOptions(), + ); try { - publication.announce(route: MoqAnnounceRoute()); expect(await next, isA()); } finally { - publication.broadcast().close(); + route.broadcast().close(); + route.dispose(); } } finally { cursor.cancel(); diff --git a/demo/carla/sim/moq_carla_sim/main.py b/demo/carla/sim/moq_carla_sim/main.py index 91e2ce38fa1..88585fb09d0 100644 --- a/demo/carla/sim/moq_carla_sim/main.py +++ b/demo/carla/sim/moq_carla_sim/main.py @@ -241,11 +241,11 @@ async def run(args: argparse.Namespace) -> None: async with moq.connect(args.url) as client: try: for truck in world.trucks: - publication = client.create_broadcast(broadcast_path(truck.name, args.camera)) + route = client.route(broadcast_path(truck.name, args.camera), hidden=True) video = await bridge.open( - bridge_stream(truck.name, args.camera), renditions(camera), publication.broadcast + bridge_stream(truck.name, args.camera), renditions(camera), route.broadcast ) - session = TruckSession(truck, publication, video, args.location, map_name, world.clock) + session = TruckSession(truck, route, video, args.location, map_name, world.clock) sessions.append(session) log.info( "publishing %s, consuming %s/*", diff --git a/demo/carla/sim/moq_carla_sim/runtime.py b/demo/carla/sim/moq_carla_sim/runtime.py index 48c519fa27d..51aceae2e6b 100644 --- a/demo/carla/sim/moq_carla_sim/runtime.py +++ b/demo/carla/sim/moq_carla_sim/runtime.py @@ -93,19 +93,21 @@ class TruckSession: def __init__( self, truck: Truck, - publication: moq.Publication, + route: moq.Route, video: BridgeStream, location: str | None, map_name: str | None, clock: Clock, ) -> None: self._truck = truck + # Held for the session's lifetime: dropping the route retracts the broadcast. + self._route = route self._video = video self._clock = clock self._location = location self._map_name = map_name self._drive = Drive() - self._status = StatusPublisher(publication.broadcast) + self._status = StatusPublisher(route.broadcast) # Bounded: drivers are untrusted publishers, so an unbounded queue is a # memory-growth path under one that floods; a full queue instead blocks # that driver's own task. @@ -113,12 +115,12 @@ def __init__( self._media_pipeline: dict[str, list[dict[str, Any]]] = {} self._deadline: float | None = None self._stuck = StuckDetector() - # A broadcast starts unadvertised; the player discovers trucks by - # announcement, so advertise once the status and video tracks exist. - # The stream's are there already: opening it advertised them. The - # broadcast mints a fresh instance, so viewers see a restarted worker - # as a restart rather than a stalled broadcast. - publication.announce() + # The route starts hidden; the player discovers trucks by announcement, + # so advertise once the status and video tracks exist. The stream's are + # there already: opening it advertised them. The route mints a fresh + # instance, so viewers see a restarted worker as a restart rather than + # a stalled broadcast. + route.announce() def stop_video(self) -> None: """End the video tracks cleanly and stop the bridge encoding them.""" diff --git a/demo/carla/sim/tests/test_runtime.py b/demo/carla/sim/tests/test_runtime.py index 61a73a1e333..cfa4b76d015 100644 --- a/demo/carla/sim/tests/test_runtime.py +++ b/demo/carla/sim/tests/test_runtime.py @@ -534,10 +534,10 @@ def respawn() -> None: def test_session_announces_after_populating_tracks() -> None: - """The player lists trucks from announcements, and a broadcast starts - unadvertised, so a session that never announces is invisible.""" + """The player lists trucks from announcements, and the route starts hidden, + so a session that never announces is invisible.""" calls: list[object] = [] - publication = SimpleNamespace( + route = SimpleNamespace( broadcast=SimpleNamespace( publish_track=lambda name, info=None: calls.append(name) or SimpleNamespace(demand=SimpleNamespace) ), @@ -545,7 +545,7 @@ def test_session_announces_after_populating_tracks() -> None: ) # The video tracks were advertised when the bridge stream opened. - runtime.TruckSession(SimpleNamespace(name="truck1"), publication, SimpleNamespace(), None, None, FakeClock()) + runtime.TruckSession(SimpleNamespace(name="truck1"), route, SimpleNamespace(), None, None, FakeClock()) assert calls == ["status", "announce"] diff --git a/demo/carla/web/src/truck.ts b/demo/carla/web/src/truck.ts index e16c27b7eeb..cbba17bf2ce 100644 --- a/demo/carla/web/src/truck.ts +++ b/demo/carla/web/src/truck.ts @@ -414,8 +414,8 @@ export class Truck { const driverId = crypto.randomUUID().replaceAll("-", "").slice(0, 12); this.driverId.set(driverId); - const driverBroadcastPublication = conn.createBroadcast(driverPath(this.name, driverId)); - const driverBroadcast = driverBroadcastPublication.broadcast; + const driverBroadcastRoute = conn.route(driverPath(this.name, driverId), { hidden: true }); + const driverBroadcast = driverBroadcastRoute.broadcast; effect.cleanup(() => { driverBroadcast.close(); this.driverId.set(undefined); @@ -429,7 +429,7 @@ export class Truck { effect.run(this.#runControlTrack.bind(this, track, producer)); // A fresh instance per lease, which each publication mints: the truck binds its single // driver to this path and instance. - driverBroadcastPublication.announce(); + driverBroadcastRoute.announce(); } #runSafetyControl(connection: Moq.Connection, effect: Moq.Signals.Effect) { @@ -438,10 +438,10 @@ export class Truck { if (!conn || !command) return; const driverId = `safety-${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`; - const broadcastPublication = conn.createBroadcast(driverPath(this.name, driverId)); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = conn.route(driverPath(this.name, driverId), { hidden: true }); + const broadcast = broadcastRoute.broadcast; const safety = new SafetyCommandBroadcast(command, broadcast); - broadcastPublication.announce(); + broadcastRoute.announce(); effect.cleanup(() => safety.close()); } diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 74f33f1bb20..8172d1dd97a 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -277,11 +277,7 @@ audio-device dependencies: cargo install moq-cli --no-default-features --features "quic,websocket,play,openh264" ``` -`moq play` skips disabled renditions when it picks one. If no enabled rendition -matches its name and codec filters, it waits for a later catalog. Disabling the -current rendition switches to an enabled one of its kind that matches the -filters; with none, it stays subscribed, so re-enabling it (an unmute) plays at -once. +`moq play` skips disabled renditions. If none are enabled, it waits for a later catalog. Disabling the current rendition stops it and selects an enabled replacement. ## Capture diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index 51011a50dc1..4d89b85551b 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -185,9 +185,4 @@ To decrypt a capture, set `quic-keylog=true` on either element along with `SSLKEYLOGFILE`; the variable alone writes nothing, and the flag without it fails the start. Existing Unix log files must be private (mode 0600); files accessible to group or others are refused. -`moqsrc` initially selects only enabled renditions. When a selected rendition is -disabled, it keeps reading on the same pad while no rendition of its kind is -enabled, so media arriving before the re-enable catalog update still plays. An -enabled alternative stops the disabled reader but keeps its pad idle without EOS; -enabling it again resumes on that pad. Removing a rendition drains its track and -ends its pad. +`moqsrc` creates source pads only for enabled renditions. Disabling a rendition stops its media and ends its pad with EOS; enabling it again adds a new pad, which starts at the live edge like any added rendition. Removing a rendition drains its track and ends its pad. diff --git a/doc/bin/hls.md b/doc/bin/hls.md index 33f088adec9..4615307572b 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -56,7 +56,7 @@ What that means for you: - **The publisher declares the interval.** Only a broadcast whose catalog carries `media.join.interval` gets playlists. Rust publishers built on `moq-mux` (the CLI, the RTMP, SRT, and WebRTC gateways, and the native bindings) declare it: an encoder that knows its GOP passes it as a hint, and an ingest without one measures each track over its first second or group, whichever is longer, then declares the longest. `@moq/publish` declares its keyframe interval. - **Every rendition shares the grid.** A group spans from its first frame's timestamp to the next group's and belongs to the first grid line it contains, or, containing none, the line it starts after. Segment N therefore starts at the group containing N times the segment length, every rendition numbers its segments alike, and each cuts its boundaries at its own keyframes. A grid line no group starts a segment at, because a GOP ran past it or the content paused, is listed as an `EXT-X-GAP` so the numbering never skips: on a 2 s grid, a GOP from 0 to 7 s is segment 0, segments 1 and 2 are gaps, and the next GOP starts segment 3. Timestamps that go back start a discontinuity on that rendition alone; `EXT-X-PROGRAM-DATE-TIME`, written after every discontinuity when the catalog has a clock, lets players resync. -- **Contribution media starts on selection.** The HTTP multivariant playlist reads each root `contributions` entry's catalog and lists its audio and video beside the broadcast's own. A contribution shares the broadcast's timeline: its renditions are segmented on the broadcast's join interval and clock, whatever its own catalog declares, so publish it on the same timestamps. One not yet routed is listed once its broadcast is announced. Reading its catalog starts no encoding: only a request to one of its rendition URLs subscribes to that rendition's media. Demand lasts through active responses and, after the last one finishes, for three target durations or the playlist window plus one target duration, whichever is longer (20 s by default); a later request starts it again. The broadcast's own renditions stay subscribed. A segment is servable for as long as the relay's [cache](/bin/relay/config#cache) retains it. Keep `--window` (the playlist length, default 16 s) within that retention and within the media's max age (30 s by default), after which a superseded segment's groups are stale. +- **Include media starts on selection.** The HTTP multivariant playlist reads each root `include` entry's catalog and lists its audio and video beside the broadcast's own. An include shares the broadcast's timeline: its renditions are segmented on the broadcast's join interval and clock, whatever its own catalog declares, so publish it on the same timestamps. One not yet routed is listed once its broadcast is announced. Reading its catalog starts no encoding: only a request to one of its rendition URLs subscribes to that rendition's media. Demand lasts through active responses and, after the last one finishes, for three target durations or the playlist window plus one target duration, whichever is longer (20 s by default); a later request starts it again. The broadcast's own renditions stay subscribed. A segment is servable for as long as the relay's [cache](/bin/relay/config#cache) retains it. Keep `--window` (the playlist length, default 16 s) within that retention and within the media's max age (30 s by default), after which a superseded segment's groups are stale. - **Live joins cost the window.** The exporter subscribes 30 s behind the live edge and lists at most 256 segments. One joining partway through a segment lists nothing before the first segment it sees start. - **Segments are timed by the grid.** A segment is listed once a later one starts or the track ends. It starts at its first group's timestamp, or at its line when that group starts after it (as one resuming from a pause does), or a gap at its line, and lasts until the next segment starts, so a segment's bytes never change with a catalog estimate. A GOP longer than the segment is listed at its first line, so its duration understates its media. - **The target duration is twice the segment.** Every media playlist keeps one `EXT-X-TARGETDURATION` while its rendition stays in the catalog, even across a reconfigure: twice the segment length, rounded up to whole seconds (twice the interval for a GOP of a second or more, 2 s for 20 ms audio groups), since the GOP containing a grid line starts at most one interval before it, and a gap ends under two segment lengths after its line. Only a final GOP longer than that is listed past it, against RFC 8216's rule: hls.js plays it, but Apple's `mediastreamvalidator` flags it, so keep keyframe intervals within the declared interval. @@ -65,13 +65,13 @@ What that means for you: - **URLs name the stream.** Every URL the multivariant playlist and DASH manifest list sits under `@`, the stream's instance, so every edge and every reload derive the same URLs and a CDN in front of export caches them. A restarted publisher gets URLs of its own, whose numbering starts over. The multivariant playlist and manifest switch to it at once, while the old stream's playlists end at the segments they listed before the switch, even if its publisher keeps sending; a player already on it plays those out and then has to reload the multivariant playlist. A stream that reached export over moq-lite-06 or older, or moq-transport, has an instance its session minted, so only that export serves its URLs. - **Only a clean finish ends a playlist.** `EXT-X-ENDLIST` and a `static` DASH manifest mean the media tracks finished, or, on a replaced stream's media playlists, that another publisher took the broadcast over. A rendition whose media track fails instead, such as a publisher dropping its session, answers HTTP 500 with `Cache-Control: no-store`, so a truncated recording never looks complete, and leaves the multivariant playlist and DASH manifest so players switch to a sibling. With none left, the DASH manifest answers the error too. -Contribution URLs nest under the served broadcast's (see -[`Contribution`](https://docs.rs/moq-hls/latest/moq_hls/export/struct.Contribution.html)), so +Include URLs nest under the served broadcast's (see +[`Include`](https://docs.rs/moq-hls/latest/moq_hls/export/struct.Include.html)), so access middleware sees the parent broadcast's path, while the server resolves each -[contribution](/draft/moq-hang#contributions) through its own scoped origin and leaves out -one it may not read. They follow contribution restarts unless the entry pins an `instance`; -an unavailable contribution leaves the multivariant playlist until it returns. DASH and -`--output` include only the broadcast's own renditions; record a contribution as its own +[include](/draft/moq-hang#include) through its own scoped origin and leaves out +one it may not read. They follow include restarts unless the entry pins an `instance`; +an unavailable include leaves the multivariant playlist until it returns. DASH and +`--output` include only the broadcast's own renditions; record an include as its own broadcast. A [broadcast archive](/concept/archives) is served the same diff --git a/doc/bin/obs.md b/doc/bin/obs.md index b91e8ea8992..72fbabe86da 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -12,12 +12,12 @@ OBS Studio install. - **Subscribe**: add a "MoQ Source", enter the relay URL and broadcast path, and the stream appears in the scene. - **Dock**: a **MoQ** dock with Go Live, encoding that uses the OBS Output settings or custom settings for this stream only, and live stats (protocol, RTT, bandwidth, loss), and the reason while a stream connects or reconnects. -Each Start Streaming publishes under a fresh instance. The client's automatic +Each Start Streaming publishes under a fresh instance, which the dock shows. The client's automatic reconnects keep the same instance. A stop/start, or an OBS auto-reconnect after the client gives up, republishes the broadcast under a new instance, which viewers see as a new stream. -MoQ Source plays the best enabled audio and video rendition it can decode and place. It follows catalog changes while keeping the current audio and video playing until a replacement produces decoded output. A decoder setup refusal tries the next ranked rendition, including when a supported codec name carries a description the decoder cannot use. A later peer or decode failure leaves the current rendition playing. Disabling every rendition of a media kind keeps the one already playing; removing the media kind from the catalog clears it. It also follows a restarted or replaced publisher, including a sibling broadcast the catalog points at, playing the new one from its first keyframe. +MoQ Source plays the best enabled audio and video rendition it can decode and place. It follows catalog changes while keeping the current audio and video playing until a replacement produces decoded output. A decoder setup refusal tries the next ranked rendition, including when a supported codec name carries a description the decoder cannot use. A later peer or decode failure leaves the current rendition playing. Disabling or removing every rendition of a media kind clears it. It also follows a restarted or replaced publisher, including a sibling broadcast the catalog points at, playing the new one from its first keyframe. The source plays Opus, AAC-LC (mono or stereo), and PCM audio. A channel count names its speakers by the WAVE convention: 1, 2, 3 (2.1), 6 (5.1), and 8 (7.1) play on the matching OBS layout, while quad and 5.0 are upmixed to 5.1 and 6.1 to 7.1, which OBS lacks. Other counts are refused. diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 346e45ca086..20b76f970c7 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -306,6 +306,7 @@ connect = ["https://us-east.example.com/?cost=10"] # Peers to dial. ?cost pric node = "https://us-west.example.com/" # This relay's own URL. token = "cluster.jwt" # JWT for dials without an inline ?jwt=. id = 12345 # Stable Hop ID across restarts. +route_key = "route.key" # Route key; relays sharing the file fail over as one to scoped peers. max_hops = 32 # Longest accepted/forwarded hop path (1 to 32). [cluster.api] diff --git a/doc/concept/audio-jitter.md b/doc/concept/audio-jitter.md index 9c44549f6ef..cfd03a5d28c 100644 --- a/doc/concept/audio-jitter.md +++ b/doc/concept/audio-jitter.md @@ -103,13 +103,20 @@ observe(arrival, media): origin = arrival; interval = 0; peak = delay; return index = floor((arrival - origin) / RESAMPLE) + resumed = ended; ended = false if index <= interval: peak = max(peak, delay); return fold(bucket of peak) - fold(nothing) for each elapsed empty interval, at most IDLE_MAX + if resumed: # a declared silence folds nothing + origin = arrival; index = 0 + else: + fold(nothing) for each elapsed empty interval, at most IDLE_MAX interval = index; peak = delay +end(): # a declared end precedes the next observation + if not first observation: ended = true + fold(bucket): # prior starts at 1 rest = 1 - FORGET * (1 - prior) # what the decayed evidence leaves fresh = min(FRESH_MAX, rest) if bucket else 0 @@ -474,6 +481,25 @@ keeps decaying at the base rate. Only the weight the prior regained is replaced quickly, the same way it is at cold start, since it stands for no evidence at all. +### Declared silence + +A publisher that stops sending on purpose, such as a muted push-to-talk +speaker, marks the stop with a declared end. The silence after it says +nothing about the path, so relaxing toward the prior would only make a +listener who sat through a long mute come back over-buffered. `end()` tells +the estimator that a declared end lies between the previous observation and +the next. The next measured observation then folds the pending interval as +usual and starts a fresh interval at its own arrival instead of folding the +intervals in between. If it lands in the pending interval it is merged as +usual, since there is nothing to skip. Either way the end is spent, so a +later gap the publisher did not declare still relaxes the estimate. The +reference history, the histogram, and the prior are untouched, and an end +before the first observation, or under a fixed target, does nothing. + +The receiver calls `end()` before observing the first frame from a group later +than the declared end's own: the stopped encoder's final packets may follow the +marker in its group, and they belong to the speech before the silence, not after it. + ## Fixed targets An explicit delay **fixes the target and disables adaptation**. It is taken @@ -552,7 +578,8 @@ directly, so neither is graded against the other. `arrival` and `media` are parallel arrays in **delivery order**, the order the receiver observes them, so `media` is deliberately out of order in the `reorder` and `backlog` cases. `advertised` and `delay` are present only where the case uses -them. `target` lists `[frame index, target]` at every frame where the target +them, as is `ends`: the indices, ascending, of the frames observed right after a +[declared end](#declared-silence), each of which calls `end()` before it is observed. `target` lists `[frame index, target]` at every frame where the target differs from the previous frame's, starting at frame 0, so an implementation folds the trace one frame at a time and compares its target at each listed index. Tolerance is 1e-6 ms, which the `f64` rules above make generous. @@ -566,6 +593,7 @@ index. Tolerance is 1e-6 ms, which the `f64` rules above make generous. | `reorder` | A run delivered after a newer one is measured against the frames before it, so the target covers waiting for it | | `backlog` | A backlog delivered newest first behind the live edge is not measured, so its media span never reaches the target | | `idle` | A 30 s gap relaxes the histogram toward the prior | +| `silence` | A declared 60 s silence and a declared pause leave the target where it was, and a later undeclared gap still relaxes it | | `tunein` | A stale frame then the live edge 14.5 s later leaves the target near the frame duration. This is the #3517 regression | | `spike` | A recurring stall raises the target, and it decays once the stalls stop | | `straggler` | One late interval after the prior is spent leaves the target where it was | diff --git a/doc/concept/audio-jitter/corpus.ts b/doc/concept/audio-jitter/corpus.ts index 6af6626f491..299328f8170 100644 --- a/doc/concept/audio-jitter/corpus.ts +++ b/doc/concept/audio-jitter/corpus.ts @@ -24,6 +24,8 @@ export interface Case { arrival: number[]; /** Each frame's container timestamp in milliseconds, in the same order. */ media: number[]; + /** Indices of the frames observed right after a declared end, in ascending order. */ + ends?: number[]; /** `[frame index, target]` wherever the target differs from the previous frame's. */ target: [number, number][]; } @@ -87,14 +89,19 @@ function paced(frames: number, frame: number, base: number): Trace { } /** Replay a trace through the reference and keep only the frames where the target moved. */ -function run(trace: Trace, config: { frame: number; advertised?: number; delay?: number }): [number, number][] { +function run( + trace: Trace & { ends?: number[] }, + config: { frame: number; advertised?: number; delay?: number }, +): [number, number][] { if (config.delay !== undefined) return [[0, config.delay]]; const estimator = new Estimator({ frame: config.frame, advertised: config.advertised }); const target: [number, number][] = []; let previous: number | undefined; + const ends = new Set(trace.ends); for (let i = 0; i < trace.arrival.length; i++) { + if (ends.has(i)) estimator.end(); estimator.observe({ arrival: trace.arrival[i], media: trace.media[i] }); const value = estimator.target; if (value !== previous) target.push([i, value]); @@ -171,6 +178,13 @@ const traces: Omit[] = [ frame: FRAME, ...gap(paced(1000, FRAME, 50), 500, 30_000), }, + { + name: "silence", + about: "Even audio, a declared end and 60 s of silence, 5 s more, a declared 100 ms pause inside one interval, 5 s more, then an undeclared 30 s gap. Unlike `idle`, a declared silence is not evidence, so the target holds through both; the undeclared gap still relaxes it, since an end covers only the silence it declares.", + frame: FRAME, + ...gap(gap(gap(paced(1500, FRAME, 50), 500, 60_000), 760, 100), 1000, 30_000), + ends: [500, 760], + }, { name: "tunein", about: "One frame from a stale group, then the live edge 14.5 s later, then 30 s of even audio. The #3517 branch reported 14.56 s here; the target must stay near the frame duration.", diff --git a/doc/concept/audio-jitter/reference.ts b/doc/concept/audio-jitter/reference.ts index f2d92ee387f..ecd7a2fd3aa 100644 --- a/doc/concept/audio-jitter/reference.ts +++ b/doc/concept/audio-jitter/reference.ts @@ -73,6 +73,8 @@ export class Estimator { #interval = 0; /** The largest delay seen in that interval. */ #peak = 0; + /** A declared end precedes the next measured observation, so the silence before it is not idle. */ + #ended = false; #histogram = prior(); /** How much of the histogram is still the prior's rather than evidence. */ @@ -119,6 +121,8 @@ export class Estimator { } const interval = Math.floor((arrival - this.#origin) / RESAMPLE); + const resumed = this.#ended; + this.#ended = false; if (interval <= this.#interval) { this.#peak = Math.max(this.#peak, delay); return; @@ -126,15 +130,27 @@ export class Estimator { this.#fold(Math.min(BUCKETS - 1, Math.floor(this.#peak / BUCKET))); - const idle = Math.min(interval - this.#interval - 1, IDLE_MAX); - for (let i = 0; i < idle; i++) { - this.#fold(undefined); + if (resumed) { + // The publisher declared the silence, so it is not evidence of anything: the + // estimate keeps what it had and a fresh interval starts at this arrival. + this.#origin = arrival; + this.#interval = 0; + } else { + const idle = Math.min(interval - this.#interval - 1, IDLE_MAX); + for (let i = 0; i < idle; i++) { + this.#fold(undefined); + } + this.#interval = interval; } - - this.#interval = interval; this.#peak = delay; } + /** A declared end lies between the previous observation and the next. */ + end(): void { + // Before any observation there is no interval for the silence to stretch. + if (this.#origin !== undefined) this.#ended = true; + } + /** The target the receiver holds, in milliseconds. */ get target(): number { let cumulative = 0; diff --git a/doc/concept/audio-jitter/silence.json b/doc/concept/audio-jitter/silence.json new file mode 100644 index 00000000000..60df4c38167 --- /dev/null +++ b/doc/concept/audio-jitter/silence.json @@ -0,0 +1 @@ +{"name":"silence","about":"Even audio, a declared end and 60 s of silence, 5 s more, a declared 100 ms pause inside one interval, 5 s more, then an undeclared 30 s gap. Unlike `idle`, a declared silence is not evidence, so the target holds through both; the undeclared gap still relaxes it, since an end covers only the silence it declares.","frame":20,"arrival":[50,70,90,110,130,150,170,190,210,230,250,270,290,310,330,350,370,390,410,430,450,470,490,510,530,550,570,590,610,630,650,670,690,710,730,750,770,790,810,830,850,870,890,910,930,950,970,990,1010,1030,1050,1070,1090,1110,1130,1150,1170,1190,1210,1230,1250,1270,1290,1310,1330,1350,1370,1390,1410,1430,1450,1470,1490,1510,1530,1550,1570,1590,1610,1630,1650,1670,1690,1710,1730,1750,1770,1790,1810,1830,1850,1870,1890,1910,1930,1950,1970,1990,2010,2030,2050,2070,2090,2110,2130,2150,2170,2190,2210,2230,2250,2270,2290,2310,2330,2350,2370,2390,2410,2430,2450,2470,2490,2510,2530,2550,2570,2590,2610,2630,2650,2670,2690,2710,2730,2750,2770,2790,2810,2830,2850,2870,2890,2910,2930,2950,2970,2990,3010,3030,3050,3070,3090,3110,3130,3150,3170,3190,3210,3230,3250,3270,3290,3310,3330,3350,3370,3390,3410,3430,3450,3470,3490,3510,3530,3550,3570,3590,3610,3630,3650,3670,3690,3710,3730,3750,3770,3790,3810,3830,3850,3870,3890,3910,3930,3950,3970,3990,4010,4030,4050,4070,4090,4110,4130,4150,4170,4190,4210,4230,4250,4270,4290,4310,4330,4350,4370,4390,4410,4430,4450,4470,4490,4510,4530,4550,4570,4590,4610,4630,4650,4670,4690,4710,4730,4750,4770,4790,4810,4830,4850,4870,4890,4910,4930,4950,4970,4990,5010,5030,5050,5070,5090,5110,5130,5150,5170,5190,5210,5230,5250,5270,5290,5310,5330,5350,5370,5390,5410,5430,5450,5470,5490,5510,5530,5550,5570,5590,5610,5630,5650,5670,5690,5710,5730,5750,5770,5790,5810,5830,5850,5870,5890,5910,5930,5950,5970,5990,6010,6030,6050,6070,6090,6110,6130,6150,6170,6190,6210,6230,6250,6270,6290,6310,6330,6350,6370,6390,6410,6430,6450,6470,6490,6510,6530,6550,6570,6590,6610,6630,6650,6670,6690,6710,6730,6750,6770,6790,6810,6830,6850,6870,6890,6910,6930,6950,6970,6990,7010,7030,7050,7070,7090,7110,7130,7150,7170,7190,7210,7230,7250,7270,7290,7310,7330,7350,7370,7390,7410,7430,7450,7470,7490,7510,7530,7550,7570,7590,7610,7630,7650,7670,7690,7710,7730,7750,7770,7790,7810,7830,7850,7870,7890,7910,7930,7950,7970,7990,8010,8030,8050,8070,8090,8110,8130,8150,8170,8190,8210,8230,8250,8270,8290,8310,8330,8350,8370,8390,8410,8430,8450,8470,8490,8510,8530,8550,8570,8590,8610,8630,8650,8670,8690,8710,8730,8750,8770,8790,8810,8830,8850,8870,8890,8910,8930,8950,8970,8990,9010,9030,9050,9070,9090,9110,9130,9150,9170,9190,9210,9230,9250,9270,9290,9310,9330,9350,9370,9390,9410,9430,9450,9470,9490,9510,9530,9550,9570,9590,9610,9630,9650,9670,9690,9710,9730,9750,9770,9790,9810,9830,9850,9870,9890,9910,9930,9950,9970,9990,10010,10030,70050,70070,70090,70110,70130,70150,70170,70190,70210,70230,70250,70270,70290,70310,70330,70350,70370,70390,70410,70430,70450,70470,70490,70510,70530,70550,70570,70590,70610,70630,70650,70670,70690,70710,70730,70750,70770,70790,70810,70830,70850,70870,70890,70910,70930,70950,70970,70990,71010,71030,71050,71070,71090,71110,71130,71150,71170,71190,71210,71230,71250,71270,71290,71310,71330,71350,71370,71390,71410,71430,71450,71470,71490,71510,71530,71550,71570,71590,71610,71630,71650,71670,71690,71710,71730,71750,71770,71790,71810,71830,71850,71870,71890,71910,71930,71950,71970,71990,72010,72030,72050,72070,72090,72110,72130,72150,72170,72190,72210,72230,72250,72270,72290,72310,72330,72350,72370,72390,72410,72430,72450,72470,72490,72510,72530,72550,72570,72590,72610,72630,72650,72670,72690,72710,72730,72750,72770,72790,72810,72830,72850,72870,72890,72910,72930,72950,72970,72990,73010,73030,73050,73070,73090,73110,73130,73150,73170,73190,73210,73230,73250,73270,73290,73310,73330,73350,73370,73390,73410,73430,73450,73470,73490,73510,73530,73550,73570,73590,73610,73630,73650,73670,73690,73710,73730,73750,73770,73790,73810,73830,73850,73870,73890,73910,73930,73950,73970,73990,74010,74030,74050,74070,74090,74110,74130,74150,74170,74190,74210,74230,74250,74270,74290,74310,74330,74350,74370,74390,74410,74430,74450,74470,74490,74510,74530,74550,74570,74590,74610,74630,74650,74670,74690,74710,74730,74750,74770,74790,74810,74830,74850,74870,74890,74910,74930,74950,74970,74990,75010,75030,75050,75070,75090,75110,75130,75150,75170,75190,75210,75230,75350,75370,75390,75410,75430,75450,75470,75490,75510,75530,75550,75570,75590,75610,75630,75650,75670,75690,75710,75730,75750,75770,75790,75810,75830,75850,75870,75890,75910,75930,75950,75970,75990,76010,76030,76050,76070,76090,76110,76130,76150,76170,76190,76210,76230,76250,76270,76290,76310,76330,76350,76370,76390,76410,76430,76450,76470,76490,76510,76530,76550,76570,76590,76610,76630,76650,76670,76690,76710,76730,76750,76770,76790,76810,76830,76850,76870,76890,76910,76930,76950,76970,76990,77010,77030,77050,77070,77090,77110,77130,77150,77170,77190,77210,77230,77250,77270,77290,77310,77330,77350,77370,77390,77410,77430,77450,77470,77490,77510,77530,77550,77570,77590,77610,77630,77650,77670,77690,77710,77730,77750,77770,77790,77810,77830,77850,77870,77890,77910,77930,77950,77970,77990,78010,78030,78050,78070,78090,78110,78130,78150,78170,78190,78210,78230,78250,78270,78290,78310,78330,78350,78370,78390,78410,78430,78450,78470,78490,78510,78530,78550,78570,78590,78610,78630,78650,78670,78690,78710,78730,78750,78770,78790,78810,78830,78850,78870,78890,78910,78930,78950,78970,78990,79010,79030,79050,79070,79090,79110,79130,79150,79170,79190,79210,79230,79250,79270,79290,79310,79330,79350,79370,79390,79410,79430,79450,79470,79490,79510,79530,79550,79570,79590,79610,79630,79650,79670,79690,79710,79730,79750,79770,79790,79810,79830,79850,79870,79890,79910,79930,79950,79970,79990,80010,80030,80050,80070,80090,80110,80130,110150,110170,110190,110210,110230,110250,110270,110290,110310,110330,110350,110370,110390,110410,110430,110450,110470,110490,110510,110530,110550,110570,110590,110610,110630,110650,110670,110690,110710,110730,110750,110770,110790,110810,110830,110850,110870,110890,110910,110930,110950,110970,110990,111010,111030,111050,111070,111090,111110,111130,111150,111170,111190,111210,111230,111250,111270,111290,111310,111330,111350,111370,111390,111410,111430,111450,111470,111490,111510,111530,111550,111570,111590,111610,111630,111650,111670,111690,111710,111730,111750,111770,111790,111810,111830,111850,111870,111890,111910,111930,111950,111970,111990,112010,112030,112050,112070,112090,112110,112130,112150,112170,112190,112210,112230,112250,112270,112290,112310,112330,112350,112370,112390,112410,112430,112450,112470,112490,112510,112530,112550,112570,112590,112610,112630,112650,112670,112690,112710,112730,112750,112770,112790,112810,112830,112850,112870,112890,112910,112930,112950,112970,112990,113010,113030,113050,113070,113090,113110,113130,113150,113170,113190,113210,113230,113250,113270,113290,113310,113330,113350,113370,113390,113410,113430,113450,113470,113490,113510,113530,113550,113570,113590,113610,113630,113650,113670,113690,113710,113730,113750,113770,113790,113810,113830,113850,113870,113890,113910,113930,113950,113970,113990,114010,114030,114050,114070,114090,114110,114130,114150,114170,114190,114210,114230,114250,114270,114290,114310,114330,114350,114370,114390,114410,114430,114450,114470,114490,114510,114530,114550,114570,114590,114610,114630,114650,114670,114690,114710,114730,114750,114770,114790,114810,114830,114850,114870,114890,114910,114930,114950,114970,114990,115010,115030,115050,115070,115090,115110,115130,115150,115170,115190,115210,115230,115250,115270,115290,115310,115330,115350,115370,115390,115410,115430,115450,115470,115490,115510,115530,115550,115570,115590,115610,115630,115650,115670,115690,115710,115730,115750,115770,115790,115810,115830,115850,115870,115890,115910,115930,115950,115970,115990,116010,116030,116050,116070,116090,116110,116130,116150,116170,116190,116210,116230,116250,116270,116290,116310,116330,116350,116370,116390,116410,116430,116450,116470,116490,116510,116530,116550,116570,116590,116610,116630,116650,116670,116690,116710,116730,116750,116770,116790,116810,116830,116850,116870,116890,116910,116930,116950,116970,116990,117010,117030,117050,117070,117090,117110,117130,117150,117170,117190,117210,117230,117250,117270,117290,117310,117330,117350,117370,117390,117410,117430,117450,117470,117490,117510,117530,117550,117570,117590,117610,117630,117650,117670,117690,117710,117730,117750,117770,117790,117810,117830,117850,117870,117890,117910,117930,117950,117970,117990,118010,118030,118050,118070,118090,118110,118130,118150,118170,118190,118210,118230,118250,118270,118290,118310,118330,118350,118370,118390,118410,118430,118450,118470,118490,118510,118530,118550,118570,118590,118610,118630,118650,118670,118690,118710,118730,118750,118770,118790,118810,118830,118850,118870,118890,118910,118930,118950,118970,118990,119010,119030,119050,119070,119090,119110,119130,119150,119170,119190,119210,119230,119250,119270,119290,119310,119330,119350,119370,119390,119410,119430,119450,119470,119490,119510,119530,119550,119570,119590,119610,119630,119650,119670,119690,119710,119730,119750,119770,119790,119810,119830,119850,119870,119890,119910,119930,119950,119970,119990,120010,120030,120050,120070,120090,120110,120130],"media":[0,20,40,60,80,100,120,140,160,180,200,220,240,260,280,300,320,340,360,380,400,420,440,460,480,500,520,540,560,580,600,620,640,660,680,700,720,740,760,780,800,820,840,860,880,900,920,940,960,980,1000,1020,1040,1060,1080,1100,1120,1140,1160,1180,1200,1220,1240,1260,1280,1300,1320,1340,1360,1380,1400,1420,1440,1460,1480,1500,1520,1540,1560,1580,1600,1620,1640,1660,1680,1700,1720,1740,1760,1780,1800,1820,1840,1860,1880,1900,1920,1940,1960,1980,2000,2020,2040,2060,2080,2100,2120,2140,2160,2180,2200,2220,2240,2260,2280,2300,2320,2340,2360,2380,2400,2420,2440,2460,2480,2500,2520,2540,2560,2580,2600,2620,2640,2660,2680,2700,2720,2740,2760,2780,2800,2820,2840,2860,2880,2900,2920,2940,2960,2980,3000,3020,3040,3060,3080,3100,3120,3140,3160,3180,3200,3220,3240,3260,3280,3300,3320,3340,3360,3380,3400,3420,3440,3460,3480,3500,3520,3540,3560,3580,3600,3620,3640,3660,3680,3700,3720,3740,3760,3780,3800,3820,3840,3860,3880,3900,3920,3940,3960,3980,4000,4020,4040,4060,4080,4100,4120,4140,4160,4180,4200,4220,4240,4260,4280,4300,4320,4340,4360,4380,4400,4420,4440,4460,4480,4500,4520,4540,4560,4580,4600,4620,4640,4660,4680,4700,4720,4740,4760,4780,4800,4820,4840,4860,4880,4900,4920,4940,4960,4980,5000,5020,5040,5060,5080,5100,5120,5140,5160,5180,5200,5220,5240,5260,5280,5300,5320,5340,5360,5380,5400,5420,5440,5460,5480,5500,5520,5540,5560,5580,5600,5620,5640,5660,5680,5700,5720,5740,5760,5780,5800,5820,5840,5860,5880,5900,5920,5940,5960,5980,6000,6020,6040,6060,6080,6100,6120,6140,6160,6180,6200,6220,6240,6260,6280,6300,6320,6340,6360,6380,6400,6420,6440,6460,6480,6500,6520,6540,6560,6580,6600,6620,6640,6660,6680,6700,6720,6740,6760,6780,6800,6820,6840,6860,6880,6900,6920,6940,6960,6980,7000,7020,7040,7060,7080,7100,7120,7140,7160,7180,7200,7220,7240,7260,7280,7300,7320,7340,7360,7380,7400,7420,7440,7460,7480,7500,7520,7540,7560,7580,7600,7620,7640,7660,7680,7700,7720,7740,7760,7780,7800,7820,7840,7860,7880,7900,7920,7940,7960,7980,8000,8020,8040,8060,8080,8100,8120,8140,8160,8180,8200,8220,8240,8260,8280,8300,8320,8340,8360,8380,8400,8420,8440,8460,8480,8500,8520,8540,8560,8580,8600,8620,8640,8660,8680,8700,8720,8740,8760,8780,8800,8820,8840,8860,8880,8900,8920,8940,8960,8980,9000,9020,9040,9060,9080,9100,9120,9140,9160,9180,9200,9220,9240,9260,9280,9300,9320,9340,9360,9380,9400,9420,9440,9460,9480,9500,9520,9540,9560,9580,9600,9620,9640,9660,9680,9700,9720,9740,9760,9780,9800,9820,9840,9860,9880,9900,9920,9940,9960,9980,70000,70020,70040,70060,70080,70100,70120,70140,70160,70180,70200,70220,70240,70260,70280,70300,70320,70340,70360,70380,70400,70420,70440,70460,70480,70500,70520,70540,70560,70580,70600,70620,70640,70660,70680,70700,70720,70740,70760,70780,70800,70820,70840,70860,70880,70900,70920,70940,70960,70980,71000,71020,71040,71060,71080,71100,71120,71140,71160,71180,71200,71220,71240,71260,71280,71300,71320,71340,71360,71380,71400,71420,71440,71460,71480,71500,71520,71540,71560,71580,71600,71620,71640,71660,71680,71700,71720,71740,71760,71780,71800,71820,71840,71860,71880,71900,71920,71940,71960,71980,72000,72020,72040,72060,72080,72100,72120,72140,72160,72180,72200,72220,72240,72260,72280,72300,72320,72340,72360,72380,72400,72420,72440,72460,72480,72500,72520,72540,72560,72580,72600,72620,72640,72660,72680,72700,72720,72740,72760,72780,72800,72820,72840,72860,72880,72900,72920,72940,72960,72980,73000,73020,73040,73060,73080,73100,73120,73140,73160,73180,73200,73220,73240,73260,73280,73300,73320,73340,73360,73380,73400,73420,73440,73460,73480,73500,73520,73540,73560,73580,73600,73620,73640,73660,73680,73700,73720,73740,73760,73780,73800,73820,73840,73860,73880,73900,73920,73940,73960,73980,74000,74020,74040,74060,74080,74100,74120,74140,74160,74180,74200,74220,74240,74260,74280,74300,74320,74340,74360,74380,74400,74420,74440,74460,74480,74500,74520,74540,74560,74580,74600,74620,74640,74660,74680,74700,74720,74740,74760,74780,74800,74820,74840,74860,74880,74900,74920,74940,74960,74980,75000,75020,75040,75060,75080,75100,75120,75140,75160,75180,75300,75320,75340,75360,75380,75400,75420,75440,75460,75480,75500,75520,75540,75560,75580,75600,75620,75640,75660,75680,75700,75720,75740,75760,75780,75800,75820,75840,75860,75880,75900,75920,75940,75960,75980,76000,76020,76040,76060,76080,76100,76120,76140,76160,76180,76200,76220,76240,76260,76280,76300,76320,76340,76360,76380,76400,76420,76440,76460,76480,76500,76520,76540,76560,76580,76600,76620,76640,76660,76680,76700,76720,76740,76760,76780,76800,76820,76840,76860,76880,76900,76920,76940,76960,76980,77000,77020,77040,77060,77080,77100,77120,77140,77160,77180,77200,77220,77240,77260,77280,77300,77320,77340,77360,77380,77400,77420,77440,77460,77480,77500,77520,77540,77560,77580,77600,77620,77640,77660,77680,77700,77720,77740,77760,77780,77800,77820,77840,77860,77880,77900,77920,77940,77960,77980,78000,78020,78040,78060,78080,78100,78120,78140,78160,78180,78200,78220,78240,78260,78280,78300,78320,78340,78360,78380,78400,78420,78440,78460,78480,78500,78520,78540,78560,78580,78600,78620,78640,78660,78680,78700,78720,78740,78760,78780,78800,78820,78840,78860,78880,78900,78920,78940,78960,78980,79000,79020,79040,79060,79080,79100,79120,79140,79160,79180,79200,79220,79240,79260,79280,79300,79320,79340,79360,79380,79400,79420,79440,79460,79480,79500,79520,79540,79560,79580,79600,79620,79640,79660,79680,79700,79720,79740,79760,79780,79800,79820,79840,79860,79880,79900,79920,79940,79960,79980,80000,80020,80040,80060,80080,110100,110120,110140,110160,110180,110200,110220,110240,110260,110280,110300,110320,110340,110360,110380,110400,110420,110440,110460,110480,110500,110520,110540,110560,110580,110600,110620,110640,110660,110680,110700,110720,110740,110760,110780,110800,110820,110840,110860,110880,110900,110920,110940,110960,110980,111000,111020,111040,111060,111080,111100,111120,111140,111160,111180,111200,111220,111240,111260,111280,111300,111320,111340,111360,111380,111400,111420,111440,111460,111480,111500,111520,111540,111560,111580,111600,111620,111640,111660,111680,111700,111720,111740,111760,111780,111800,111820,111840,111860,111880,111900,111920,111940,111960,111980,112000,112020,112040,112060,112080,112100,112120,112140,112160,112180,112200,112220,112240,112260,112280,112300,112320,112340,112360,112380,112400,112420,112440,112460,112480,112500,112520,112540,112560,112580,112600,112620,112640,112660,112680,112700,112720,112740,112760,112780,112800,112820,112840,112860,112880,112900,112920,112940,112960,112980,113000,113020,113040,113060,113080,113100,113120,113140,113160,113180,113200,113220,113240,113260,113280,113300,113320,113340,113360,113380,113400,113420,113440,113460,113480,113500,113520,113540,113560,113580,113600,113620,113640,113660,113680,113700,113720,113740,113760,113780,113800,113820,113840,113860,113880,113900,113920,113940,113960,113980,114000,114020,114040,114060,114080,114100,114120,114140,114160,114180,114200,114220,114240,114260,114280,114300,114320,114340,114360,114380,114400,114420,114440,114460,114480,114500,114520,114540,114560,114580,114600,114620,114640,114660,114680,114700,114720,114740,114760,114780,114800,114820,114840,114860,114880,114900,114920,114940,114960,114980,115000,115020,115040,115060,115080,115100,115120,115140,115160,115180,115200,115220,115240,115260,115280,115300,115320,115340,115360,115380,115400,115420,115440,115460,115480,115500,115520,115540,115560,115580,115600,115620,115640,115660,115680,115700,115720,115740,115760,115780,115800,115820,115840,115860,115880,115900,115920,115940,115960,115980,116000,116020,116040,116060,116080,116100,116120,116140,116160,116180,116200,116220,116240,116260,116280,116300,116320,116340,116360,116380,116400,116420,116440,116460,116480,116500,116520,116540,116560,116580,116600,116620,116640,116660,116680,116700,116720,116740,116760,116780,116800,116820,116840,116860,116880,116900,116920,116940,116960,116980,117000,117020,117040,117060,117080,117100,117120,117140,117160,117180,117200,117220,117240,117260,117280,117300,117320,117340,117360,117380,117400,117420,117440,117460,117480,117500,117520,117540,117560,117580,117600,117620,117640,117660,117680,117700,117720,117740,117760,117780,117800,117820,117840,117860,117880,117900,117920,117940,117960,117980,118000,118020,118040,118060,118080,118100,118120,118140,118160,118180,118200,118220,118240,118260,118280,118300,118320,118340,118360,118380,118400,118420,118440,118460,118480,118500,118520,118540,118560,118580,118600,118620,118640,118660,118680,118700,118720,118740,118760,118780,118800,118820,118840,118860,118880,118900,118920,118940,118960,118980,119000,119020,119040,119060,119080,119100,119120,119140,119160,119180,119200,119220,119240,119260,119280,119300,119320,119340,119360,119380,119400,119420,119440,119460,119480,119500,119520,119540,119560,119580,119600,119620,119640,119660,119680,119700,119720,119740,119760,119780,119800,119820,119840,119860,119880,119900,119920,119940,119960,119980,120000,120020,120040,120060,120080],"ends":[500,760],"target":[[0,120],[75,100],[175,80],[225,60],[250,40],[1000,100],[1070,80],[1120,60],[1145,40]]} diff --git a/doc/concept/hang.md b/doc/concept/hang.md index a7b9e77fbd4..2962d673e7a 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -63,14 +63,14 @@ A few things the catalog can express beyond decoder config: - **Labels.** Any rendition may carry a human-readable `label` for a track picker. The map key stays the rendition's ID, so labels need not be unique and renaming one doesn't rename the track. - **Renditions in another broadcast.** A rendition may point at a relative broadcast path, so a transcoder can publish a ladder that adds low rungs and references the source's original rendition without re-publishing its bytes. The path resolves against where the consumer found the catalog, so a reference that escapes above the root names nothing and the catalog is rejected. `@moq/watch` and `moqsrc` follow a referenced path's announcements like their own, restarting the rendition on each instance that serves it; every other reader, such as `moq play`, the exporters, and the bindings, waits for the first one announced and keeps it, and an exporter ends the rendition with it. An optional `instance` beside the path names the [stream](/concept/moq-lite#publisher-instances) it expects: a route of another instance ends the rendition instead of falling back to it, while a route whose instance a session minted (one that crossed an older version) cannot prove one, so there it is best effort. Omit `instance` for a broadcast produced on demand, which has none until a worker answers; see [identity and recovery](/concept/identity). -- **Whole contributions.** A root `contributions` map names whole broadcasts, such as each guest feed in a composed room, by the same relative path and optional `instance` as a rendition's reference. Listing one opens nothing, so a contribution produced on demand starts only once a viewer selects it, which `@moq/watch` does with `broadcast.contribution(effect, id)`. Its rendition IDs stay its own. +- **Whole includes.** A root `include` map names whole broadcasts, such as transcoded renditions of the source, by the same relative path and optional `instance` as a rendition's reference. They share the listing catalog's PTS timeline and root settings, including its clock and join interval. Listing one opens nothing, so an include produced on demand starts only once a viewer selects it, which `@moq/watch` does with `broadcast.include(effect, id)`. Its rendition IDs stay its own. - **Track names.** The map key is a rendition's ID and, by default, its track name. A rendition sets `track` only when the two must differ, such as two renditions of same-named tracks in different broadcasts, or a rendition whose media changed and so needs a new name; readers subscribe to `track` when present. An ID can't name both a video and an audio rendition. - **Jitter.** A rendition can say how much media it writes behind a frame's timestamp, in whole milliseconds rounded up: the reorder delay of B-frames and the span of a fragment or batch written together. It is measured from media timestamps alone, never the time a frame was written, so neither a constant encoder delay nor a capture clock counts. It describes the publisher, never the network, only grows over the life of a stream, and a player sizes its buffer to at least this much. A `0` is read as absent. - **Delay.** A rendition can also say how far its frames' timestamps trail those another rendition already wrote, so a video encoder running 200 ms behind audio advertises `delay: 200` on video. A rendition alone measures none. It follows the same rules as jitter. A player holds the largest `delay + jitter` among the renditions it subscribes to, and never subtracts one rendition's `delay` from another's. - **Reorder depth.** A video rendition may declare `reorder`, the most frames that can precede one in decode order and follow it in presentation, as H.264 `max_num_reorder_frames` counts them; `0` declares none, which is not the same as absent. The Rust H.264 and H.265 importers copy it from the SPS when the SPS declares one (progressive H.264 only), and `@moq/publish` declares `0`. The MPEG-TS, Matroska, and FLV exporters then derive each group's decode times from that group's frames alone, so exports that joined at different groups agree, and they end the export on a frame reordered deeper than declared or a depth the codec level cannot reach. Without it they infer decode times from the timestamps they have read, as before. A publisher never changes it once declared. - **Colour.** A video rendition may carry `color`: the H.273 primaries, transfer, matrix, and range, plus the HDR10 mastering display and content light levels, so a renderer can set up HDR without parsing the bitstream. The Rust importers fill it from the SPS VUI, AV1 sequence header, and VP9 header, then fill gaps from a container's `colr` box or Matroska `Colour` element; the stream wins a disagreement. The HDR10 values come from H.264 and H.265 SEI or AV1 metadata OBUs in MPEG-TS and raw elementary streams, an AV1 track's av1C, or Matroska, but not yet from H.264 or H.265 in fMP4 or FLV. Nothing in `@moq/watch` reads it yet. - **Spatial media.** A video rendition may carry `projection` (flat `rectilinear`, or `equirectangular` with the degrees of longitude it spans, 180 for VR180) and `stereo` (`mono`, `left-right`, or `top-bottom` frame packing), and an audio rendition may carry `layout: "ambisonic"` for AmbiX channels. Readers treat a value they don't recognize as absent. Nothing in `@moq/watch` renders them yet. -- **Disabled renditions.** An audio or video rendition with `enabled: false` has no new frames coming, and a player should not select it. A subscriber may still get audio retained from before the disable, as when it subscribed a moment earlier. A player may keep a disabled rendition selected, and subscribed, while no enabled alternative exists, so a re-enable plays without a resubscribe; `@moq/watch` does. Pausing a rendition in `@moq/publish` keeps it in the catalog this way rather than removing it, so a mute is a one-field update; a disabled video rendition encodes one black keyframe first, so a player that predates the field shows black instead of a frozen picture. The field is written only when false. The older video `stalled` field is no longer written, and readers ignore it. +- **Disabled renditions.** An audio or video rendition with `enabled: false` has no new frames coming, and a player must not select it. A subscriber may still get audio retained from before the disable, as when it subscribed a moment earlier. Disabling a rendition in `@moq/publish`, as hiding video does, keeps it in the catalog this way rather than removing it, so it is a one-field update; a disabled video rendition encodes one black keyframe first, so a player that predates the field shows black instead of a frozen picture. The field is written only when false. The older video `stalled` field is no longer written, and readers ignore it. A muted `@moq/publish` microphone is not disabled: it stays enabled and sends nothing, ending each utterance with a discontinuity marker, so listeners stay subscribed and play the gap as intentional silence. - **Preference.** A video rendition may carry a signed `preference`, default `0`. A player keeps only the highest preference it can decode, then picks by size and bitrate within it, as do the single-rendition egresses (RTMP play, WHEP, FLV, the transcoder's source). A publisher that adds its own H.264 copy of an H.265 source marks the copy `-1`, so only a viewer that can't decode H.265 pulls it. Picking a rendition by name still overrides it. The field is written only when nonzero. - **Join interval.** The root `media.join.interval` promises that every audio and video track starts a group at least that often: the longest GOP any of them publishes. It is fixed for the broadcast, and the [HLS gateway](/bin/hls) numbers every rendition's segments on the grid it fixes, segments the smallest whole multiple of it at least a second long, from each group's first timestamp, so renditions share segment numbers. A publisher that knows its GOP declares it; Rust publishers (`moq-mux` and everything built on it) without one measure each track over its first second or group, whichever is longer, and `@moq/publish` declares its keyframe interval. A broadcast without it gets no HLS playlists. - **Clock.** The optional `media.clock` maps PTS zero to wall time so every media track shares one fixed epoch after timescale conversion. Publish independent sources as sibling broadcasts under one prefix, such as `robot/front.hang` and `robot/rear.hang`; each broadcast's audio and video entries are renditions of that source. In one Rust publisher, pass the same `moq_mux::Clock` to each `catalog::Config::with_clock`; JS publishers on one page already share the page clock. Consumers discover the siblings by prefix and align their timestamps through the advertised wall mapping; `@moq/watch` plays one broadcast at a time. diff --git a/doc/concept/identity.md b/doc/concept/identity.md index 681c50d54c4..5b2ac5021cb 100644 --- a/doc/concept/identity.md +++ b/doc/concept/identity.md @@ -173,26 +173,28 @@ already served, start a new prefix route. Its first subscription stays on the prefix route without a restart. A job that may restart independently, such as a transcoder claiming -`transcode`, instead prepares and publishes each requested path. A claim +`transcode`, instead creates each requested path's own exact route. A claim promises capability, not content. Each requested path is a separate job, announced at its exact path under a fresh instance of its own. ```rust -let claim = origin.requests("transcode")?; -while let Ok(request) = claim.requested_broadcast().await { +let claim = origin.route_prefix("transcode", RouteOptions::default())?; +while let Some(request) = claim.requested().await { // A fresh instance for this job, never one shared with another path or run. - let prepared = request.prepare()?; - let video = prepared.broadcast().create_track("video", info.clone())?; + // Hidden until its tracks exist. + let job = request.route(RouteOptions::hidden())?; + let video = job.broadcast().create_track("video", info.clone())?; // Announces the exact path under that instance and answers the waiting request. - let output = prepared.publish()?; - spawn(run_job(video, output)); + job.announce()?; + spawn(run_job(video, job)); } ``` -JavaScript mirrors this with `request.prepare()` and `prepared.publish()`; -see [`@moq/net`](/lib/js/net) and [`moq-net`](/lib/rs/moq-net). -A prepared answer is unannounced until published, and dropping it unpublished -refuses the request. +JavaScript mirrors this with `request.route({ hidden: true })` and +`job.announce()`; see [`@moq/net`](/lib/js/net) and [`moq-net`](/lib/rs/moq-net). +A hidden job route is unannounced until it announces, and dropping it before +then refuses the request. Ending a job's route withdraws only its path: the +prefix route keeps answering later requests. A lazy job's lifecycle, as a viewer of `transcode/alice` sees it through a relay: @@ -212,7 +214,7 @@ A lazy job's lifecycle, as a viewer of `transcode/alice` sees it through a relay second request reaches job A. 3. **Steady state.** Requests resolve instance A by its exact route. Routes carrying instance A through other relays fail over without a seam. -4. **Job restart.** The worker closes job A's publication. The path falls back +4. **Job restart.** The worker drops job A's route. The path falls back to the claim, the next request asks the worker again, and job B publishes under a fresh instance. Followers are handed each new broadcast; relays drop job A's cached groups, so a viewer returning within the cache window never diff --git a/doc/concept/socket.md b/doc/concept/socket.md index 8073f8a1ca9..f9f609c8692 100644 --- a/doc/concept/socket.md +++ b/doc/concept/socket.md @@ -107,9 +107,9 @@ track nobody serves. A dial that gives up resets `B`'s subscription in turn. ## Protocols Each side writes its own half and reads the peer's. A reader subscribes -reliably and in order: oldest group first, with no delay budget, so it waits -for a late group rather than skipping it. The protocol decides what a half -holds. +reliably and in order: from the half's first frame, oldest first, with no delay +budget, so it waits for a late group rather than skipping it. The protocol +decides what a half holds. ### `tcp` @@ -119,6 +119,9 @@ exactly like one direction of a TCP connection: - A frame is a write. The reader reads the frames in the order they were written, with nothing skipped. +- The reader joins the live group, which is the half's only one, from its + first frame. Over moq-transport, that is a `SUBSCRIBE` plus a joining `FETCH` + (or a fill) for the frames written before its answer. - Finishing the group is FIN: the reader reads to the end, then sees the end. - Resetting the group, or its track, is RST: the reader fails with the error. - A second group on a session track is a protocol error: the reader resets the @@ -129,12 +132,12 @@ budget, no order, since the newest group is never stale. A session can last indefinitely: a group has no size cap, and each copy keeps only a bounded tail of it. A writer that waits for room before each write (in -Rust, `group::Producer::ready`) runs at most a window ahead of the reader on +Rust, `group::Producer::ready`; in the bindings, `GroupProducer.ready(size)`) runs at most a window ahead of the reader on every hop, so a slow reader slows the writer, as TCP backpressure does, and nothing is lost. A writer that doesn't wait sheds the front of its half, and a reader that falls behind it fails with an eviction error instead of skipping data. Pacing reaches across native QUIC and qmux hops; see -[unbounded groups](https://github.com/moq-dev/moq/tree/main/quest/m1/unbounded-groups) +[unbounded groups](https://github.com/moq-dev/moq/tree/main/quest/m2/unbounded-groups) for what remains, such as browsers, which read without pacing. Use `tcp` for a byte stream or a log of records that must all arrive in order, @@ -149,7 +152,11 @@ a newer message never makes an older one stale. A reader that keeps up gets every message, but each hop's cache reclaims a message nobody read within its idle window (30 s by default), or under memory pressure, except the newest, so a reader that stalls -longer can miss messages. +longer can miss messages. Any message a hop reports it will never deliver is +skipped the same way, rather than ending the session. Over moq-transport, the +reader starts at group 0: a `SUBSCRIBE` with no filter, plus one range `FETCH` +for the groups below the answer's largest, so a publisher that refuses that +`FETCH` leaves a burst written before the answer missed. The reader's subscription starts reliable and in order, like `tcp`. An application may make it lossy for messages that go stale, such as positions or diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 472e84e7dcb..b41ec2c525d 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -28,7 +28,7 @@ subset drops. What a peer actually observes against this implementation: - **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. - **We list no tracks by `PUBLISH`.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. Our own peers list tracks over moq-lite instead. - **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end fails the listing, and readers list again. Our own moq-transport publishers refuse it, so a Rust listing of one fails. -- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust copy resuming a draft-18 subscription joins live, so once the track has moved on, a read under a finite max age gives up the history it cached before rather than fetching it again. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. +- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. An explicit absolute subscription start is served from that group on every supported draft, so a reader keeps its prefix even when its answer arrives late. A `SUBSCRIBE` without a Location Filter joins the live group rather than replaying history. A subscriber that wants the track from group 0 sends exactly that filterless `SUBSCRIBE`, the same on every draft, and fetches the groups below the answer's Largest Object with one range `FETCH`, which our publishers refuse once it spans more than one group. A live JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. - **A requester's FIN.** On draft 18 a requester finishing its stream cancels its request, except after `TRACK_STATUS`: nothing can follow it, so the FIN only ends the requester's input and the request is still answered. From draft 20 a FIN never cancels; a reset or `STOP_SENDING` does. - **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. - **Timing.** A track whose `SUBSCRIBE_OK` or `PUBLISH` declares no `TIMESCALE` is untimed. A `SUBSCRIBE` or `FETCH` with `INCLUDE_PROPERTIES=0` gets no `TIMESCALE` in its `SUBSCRIBE_OK` or `FETCH_OK`, so its objects go out unstamped. A Rust reader that never subscribed takes the units from `TRACK_STATUS`, and still asks for the properties whenever it subscribes to or fetches a timed track. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index d86d4e7af12..ebb435ee7d7 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -68,29 +68,33 @@ if (session < 0) ```c const char *path = "my-stream.hang"; -uint32_t broadcast; -int publication = moq_origin_create_broadcast(origin, path, strlen(path), &broadcast); -if (publication < 0) +moq_route_options options; +memset(&options, 0, sizeof(options)); // zeroed means visible, cost 0, a fresh instance +options.hidden = true; // fill the catalog before anyone finds it +int route = moq_origin_route(origin, path, strlen(path), &options); +if (route < 0) return fail(moq_error()); -// Fill tracks on broadcast here; announce on the publication. - -moq_announce_route route; -memset(&route, 0, sizeof(route)); // zeroed means cost 0 and the minted instance -if (moq_publication_announce((uint32_t)publication, &route) < 0) +int broadcast = moq_route_broadcast((uint32_t)route); +// Fill tracks on broadcast here, then make the route visible. +if (moq_route_announce((uint32_t)route) < 0) return fail(moq_error()); ``` -A route carries only a cost and the stream's instance; relays add the hops. Each -publication mints a fresh instance and keeps it across re-announcing, so a restarted -publisher is a new stream that `moq_origin_follow` hands viewers instead of a stalled +The route keeps the broadcast announced: `moq_route_free()` retracts it, and +`moq_publish_close()` ends both. A route carries only a cost and the stream's instance; +relays add the hops. Each route mints a fresh instance (`moq_route_instance()`) and keeps it +across `moq_route_withdraw()` and `moq_route_announce()`, so a restarted publisher, being a +new route, is a new stream that `moq_origin_follow` hands viewers instead of a stalled broadcast; the path stays `MOQ_ANNOUNCE_KIND_PRESENT` throughout. The follow callback reports each instance as `MOQ_FOLLOW_EVENT_ONLINE` with its handle, `MOQ_FOLLOW_EVENT_OFFLINE` when the path goes offline, and one `MOQ_FOLLOW_EVENT_END`. Set `instance` only for a publisher that continues its group sequences across restarts, and raise `epoch` on each: readers stitch every route of one instance together, so restarting the sequences under an old instance splices unrelated groups into -their tracks. +their tracks. To serve paths on demand, `moq_origin_route_prefix()` delivers each request to +answer with `moq_broadcast_request_serve()`, `moq_broadcast_request_route()`, or +`moq_broadcast_request_reject()`. -Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Publication track names stay separate from codec settings. For simulcast, give each rendition the next one down as the `lower` handle in its `moq_video_encode_options`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3. +Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Publication track names stay separate from codec settings. For simulcast, give each rendition the next one down as the `lower` handle in its `moq_video_encode_options`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3. For pre-encoded video, publish from the lowest rendition up and set `moq_video_init.lower` to the next lower media handle. Reserve each track with `moq_allocator_reserve_media`, then bind its reservation with `moq_rate_control_create` and call `moq_rate_control_update` on every frame. Apply changed targets to your encoder and `moq_publish_media_set_enabled`, resuming video on a keyframe; close the control and reservation separately when done. ## Things to know @@ -110,16 +114,16 @@ inside. The header is the reference: each function carries a doc comment. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `moq_publish_media_cut`: after every frame, or less often to batch frames into fewer groups. Pass that cadence as `moq_audio_init.hint.group_duration_us`. - **Catalog hints.** Give the catalog what the encoder knows instead of having it estimated: `moq_video_init.hint` (coded size, bitrate, frame rate, group duration) and `moq_audio_init.hint` (bitrate, group duration). A supplied bitrate is never re-estimated. `group_duration_us` is the longest span between two group starts: the GOP, or your cut cadence. Leave a field's `has_*` flag false to have it measured. A bitrate or `group_duration_us` flag set with a 0 value returns `MOQ_ERROR_INVALID_CONFIG`. - **Discovery.** Announcement captures are always a list of wildcard matches, empty when the filter has no wildcards. -- **Ending cleanly.** When the input ends, finish every track, then call `moq_publish_catalog_finish()` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `moq_publish_close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close ends with `UNROUTABLE` (an internal error over moq-transport, which has no such code), and its handle is released. Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). +- **Ending cleanly.** When the input ends, finish every track, then call `moq_publish_catalog_finish()` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `moq_publish_close()` or `moq_route_free()` takes it offline. How long to keep it up is yours to decide. A track still open at the close ends with `UNROUTABLE` (an internal error over moq-transport, which has no such code), and its handle is released. Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). - **Timeline breaks.** On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe. - **Timestamps.** A `moq_timestamp` is exact: `value` units at `scale` units per second, so a 90 kHz tick survives a read and republish. Frames, datagrams, and JSON values point `timestamp` at theirs, NULL on an untimed track and valid as long as the payload. Stream tracks moq-c creates are timed, so their raw writes need one. Datagram tracks are separate and default to untimed; set their timescale when creating them to require timestamps. Stamp media frames and JSON writes from `moq_publish_catalog_now()`, the broadcast clock every track in the broadcast shares; media on a timeline of its own advertises the gap to the JSON tracks as catalog `delay`. JSON writes are converted to their track's milliseconds. A NULL timestamp on any JSON write (snapshot update, stream append, window push or pop) fails with `MOQ_ERROR_INVALID_POINTER` and publishes nothing. A zero scale or an out-of-range value fails with `MOQ_ERROR_TIMESTAMP_OVERFLOW`. - **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's timestamp. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped. - **JSON windows.** `moq_publish_json_window` publishes a sliding run of records: `moq_publish_json_window_push` appends at the back, `moq_publish_json_window_pop` drops from the front, and `moq_publish_json_window_cut` closes the open group at a natural boundary. The catalog has no window mode, so a reader names the track. `moq_consume_json_window_event` fills `moq_json_window_event` with a push (the record at `start`, in `json`), a pop of `[start, end)`, or a skip of `[start, end)` that this reader joined too late to receive. Set `checkpoint_records` to bound what each new group restates for a window you never pop. - **Decoded reads.** `moq_decode_audio` and `moq_decode_video` deliver read ids in order: a frame, a gap where media was lost, or an end where the publisher stopped its encoder. Check `kind` on what `moq_decode_audio_read` or `moq_decode_video_read` writes. Free each id with `moq_decode_audio_read_free` or `moq_decode_video_read_free` promptly: a held video frame holds a decoder buffer, and the decoder stalls. A decoder reads everything the track still holds unless a live player sets `start` to `MOQ_DECODE_START_LATEST`, which joins at the newest group and skips the cached backlog. A decoder setup refusal reports `MOQ_ERROR_UNSUPPORTED_CONFIG` on its terminal callback, so the caller can try another rendition; later peer or decode failures retain their own error codes. -- **Stats.** `moq_session_stats()` fills `moq_connection_stats` with `rtt_us`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). Each has a `_valid` flag, false when the transport does not report it, which is not the same as zero. `moq_session_snapshot()` adds the negotiated `protocol`. A failed dial or dropped connection reaches the session's `on_state` with its `failure` code, and `moq_error()` gives the reason during that callback. Hold the handle returned by `moq_session_recv_bandwidth()` to request receive estimates. `moq_bandwidth_consumer_peek()` reads the estimate and `moq_bandwidth_consumer_changed()` waits for a change. `moq_bandwidth_consumer_close()` releases demand; the last consumer stops PROBE. Stats snapshots do not request it. +- **Stats.** `moq_session_stats()` fills `moq_connection_stats` with `rtt_us`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`), and `datagrams_unrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. Each has a `_valid` flag, false when the transport does not report it, which is not the same as zero. `moq_session_snapshot()` adds the negotiated `protocol`. A failed dial or dropped connection reaches the session's `on_state` with its `failure` code, and `moq_error()` gives the reason during that callback. Hold the handle returned by `moq_session_recv_bandwidth()` to request receive estimates. `moq_bandwidth_consumer_peek()` reads the estimate and `moq_bandwidth_consumer_changed()` waits for a change. `moq_bandwidth_consumer_close()` releases demand; the last consumer stops PROBE. Stats snapshots do not request it. - **Viewers.** `moq_session_viewers()` returns a handle that divides the receive estimate among the broadcasts you watch, max-min fair by each one's ceiling. `moq_viewers_watch()` returns a viewer claiming up to `max_bps`, the bitrate of the largest rendition you would switch to; `moq_viewer_rate()` reads its share in bits per second, with `present` false while there is no estimate (keep the current rendition), and `moq_viewer_update()` changes the ceiling. Holding a viewer requests the estimate like a `moq_session_recv_bandwidth()` consumer, and `moq_viewer_close()` releases the claim. The session sends the summed ceilings as its PROBE target; `moq_viewers_set_target()` with `present` true overrides that, a `0` target sends none, and `present` false follows the viewers again. `moq_viewers_watch()`, `moq_viewer_update()`, and `moq_viewers_set_target()` return `MOQ_ERROR_OUT_OF_RANGE`, changing nothing, when the ceilings or the target would pass 2^53 - 1 bps, the most a PROBE target carries. `moq_viewers_close()` releases the registry handle; viewers watched through it stay until each is closed. - **Demand.** A watcher from `moq_publish_track_demand()` (or the media, video, and audio variants) calls back `MOQ_DEMAND_WANTED` or `MOQ_DEMAND_UNWANTED`, again whenever the wanted group moves. `moq_publish_demand_wanted()` reads that group: start a new one once it passes your largest. Set `refresh` in `moq_track_info` to have a joining subscriber ask for a new group once the newest has grown past `refresh` times its first frame. A producer publishes everything you write, subscribed or not, so a late subscriber can fetch the recent past. Capturing only while the watcher reports `MOQ_DEMAND_WANTED` is an opt-in that saves the encode but leaves nothing from before the first subscriber. -- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Query the latest catalog with `moq_consume_audio_enabled` or `moq_consume_video_enabled` before selecting a rendition and opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. +- **Disabled renditions.** Audio and video consumers and decoders reject a rendition with `enabled: false`. Query the latest catalog with `moq_consume_audio_enabled` or `moq_consume_video_enabled` before selecting a rendition and opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. ## Reference diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 56f5d8c7fe3..f8fad94854b 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -137,8 +137,10 @@ A raw `moq::Frame` with `std::nullopt` is untimed. // Publish encoded frames, or raw pixels with the codec inside the binding. // session is connected as above; opus_init, packet, and rgba come from your // encoder or capture source. -auto publication = ok(session->publish()->create_broadcast("my-stream.hang")); -auto broadcast = publication->broadcast(); // fill tracks here; announce on the publication +moq::RouteOptions options; +options.hidden = true; // fill tracks before anyone finds it; hold the route to stay announced +auto route = ok(session->publish()->route("my-stream.hang", options)); +auto broadcast = ok(route->broadcast()); auto audio = ok(moq::MediaTrackProducer::audio(broadcast, moq::MediaTarget::kNamed{}, {moq::AudioFormat::kOpus, opus_init})); ok(audio->write_frame({packet, moq::Timestamp{960, 48'000}})); // 20 ms @@ -146,8 +148,8 @@ moq::VideoEncoderOutput output{moq::VideoCodec::kH264, std::nullopt, std::nullop auto catalog = ok(moq::MediaCatalogProducer::init(broadcast)); auto video = ok(moq::VideoProducer::init(catalog, {{moq::VideoPixelFormat::kRgba, 1280, 720, {30000, 1001}}, output, "camera", nullptr})); ok(video->write({{0, 30'000}, rgba})); -ok(publication->announce({})); // each broadcast mints a fresh instance -ok(broadcast->close()); // keep the producer alive while publishing, then close explicitly +ok(route->announce()); // each route mints a fresh instance, route->instance_id() +ok(broadcast->close()); // keep the producer alive while publishing, then close explicitly ``` Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings. For simulcast, give each rendition the next one down as `lower`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3. A pre-encoded video track takes `lower` in its `VideoInit` too, as a `moq::MediaProducer::kTrack` or `kStream`, which only places it one priority below; follow its reservation with `RateControl`, and while it reports disabled, drop the frames, call `set_enabled(false)` after a `discontinuity()`, and `set_enabled(true)` on the keyframe that resumes it. A `MediaTrackStreamProducer` finds its own frames: `set_enabled(false)` drops what is written to it, and after `set_enabled(true)` it resumes at the stream's next keyframe. @@ -208,7 +210,7 @@ future completed, which is the executor thread. ## Disabled renditions -A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Select an enabled rendition from the latest catalog before opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `moq::Error::kUnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. +Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `moq::Error::kUnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. ## Bandwidth estimates diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 23552e5633c..abfc1fe159c 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -81,7 +81,8 @@ print(await catalog.next()); // Publish raw pixels; the binding encodes them. rgba and pts come from your capture source. import 'package:moq/video.dart' as video; -final camera = moq.createBroadcast('live/camera'); +// Hidden until announced, so the catalog is filled before anyone finds it. +final camera = moq.route('live/camera', options: RouteOptions(hidden: true)); final encoder = video.Producer( catalog: media.CatalogProducer(broadcast: camera.broadcast()), options: video.ProducerOptions( @@ -99,7 +100,7 @@ final encoder = video.Producer( ), ); encoder.write(frame: video.Frame(timestamp: pts, data: rgba)); -camera.announce(route: AnnounceRoute()); +camera.announce(); encoder.finish(); ``` @@ -126,15 +127,15 @@ if (read is video.ReadFrame) { ```dart // Publish already-encoded frames. bytes comes from your encoder or application source. -final publication = moq.createBroadcast('live/camera'); -final mine = publication.broadcast(); +final route = moq.route('live/camera'); // visible at once, under a fresh instance +print(route.instanceId()); +final mine = route.broadcast(); final track = mine.publishTrack(name: 'video', info: null); final group = track.appendGroup(); group.writeFrame(frame: Frame(payload: bytes, timestamp: null)); group.finish(); -publication.announce(route: AnnounceRoute()); // the broadcast mints its own instance track.finish(); -mine.close(); +mine.close(); // ends the route too await moq.close(); ``` @@ -147,19 +148,20 @@ The rest of the [shared feature list](/lib/#what-every-binding-can-do) maps one to one; the API reference has the names, and [Layers](/lib/layers) shows which module holds each. -- **Names.** Types drop the `Moq` prefix (`Session`, `BroadcastProducer`) as aliases, so the generated names still work. `Container`, `Route`, and the exceptions keep it, since Flutter owns those names. +- **Names.** Types drop the `Moq` prefix (`Session`, `BroadcastProducer`, `RouteOptions`) as aliases, so the generated names still work. `MoqContainer`, `MoqRoute`, and the exceptions keep it, since Flutter owns those names. +- **Routes.** `route(path)` publishes a broadcast and `routePrefix(prefix)` answers requests beneath a prefix, each returning a `MoqRoute`. Hold it: cancelling or disposing it retracts the route, while a broadcast handle alone does not keep it announced. It keeps its instance across `withdraw()` and `announce()`, so a new run is a new route. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `cut()`: after every frame, or less often to batch frames into fewer groups. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `media.TrackConsumer`, `audio.Consumer`, and `video.Consumer` return a `Read` in order: a `ReadFrame`, a `ReadGap` where media was lost, or a `ReadEnd` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. A decoder reads everything the track still holds unless a live player sets `start: media.DecodeStart.latest` in its decoder output, which joins at the newest group and skips the cached backlog. - **Decoded frames hold decoder buffers.** `dispose()` each frame from a `video.Consumer` promptly rather than waiting for the garbage collector, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `await moq.close()` drains for up to one second and reports its result. `session.shutdown()` requests the drain synchronously; `await session.closed()` waits for its result. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Awaiting cancellation of an announcement, grant, or request subscription releases its pending native read. Announcements and grants also release their consumer; server listeners leave their shared server usable. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. -- **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline; disposing its `Publication` does not. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable` (`Internal` over moq-transport, which has no such code). Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). -- **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` or disposing its `MoqRoute` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable` (`Internal` over moq-transport, which has no such code). Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). +- **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`), and `datagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Viewers.** `client.viewers()` divides the connection's receive estimate among the broadcasts you watch, max-min fair by each one's ceiling. `watch(maxBps: ...)` returns a `Viewer` claiming up to what the largest rendition the player would switch to needs; `rate()` reads its slice in bits per second, `null` while there is no estimate, so hold the current selection. `update(maxBps: ...)` changes the ceiling, such as when the catalog enables or disables a rendition. Holding a viewer requests PROBE, and the session sends the viewers' summed ceilings as the PROBE target; `dispose()` it once the broadcast stops being watched. `setTarget(targetBps: ...)` sends another target, `0` sends none, and `null` follows the viewers again. Ceilings or a target past 2^53 - 1 bps, the most a PROBE target carries, throw `OutOfRangeMoqException`. Client viewers survive reconnects and read `null` while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. - **Tokens.** Pass `ConnectOptions(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.tokens().set_(tokens: [...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `await tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. -- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Select an enabled rendition from the latest catalog before opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `UnsupportedConfigMoqException` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. +- **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `UnsupportedConfigMoqException` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** A bare `Subscription()`, passing no subscription, and `defaultSubscription()` all mean moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once. `Delay` defaults both bounds to zero: pass `max: null` for no subscriber limit, or `min: null` to leave the target latency unstated. Decoders take the same `Delay`. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. A producer publishes everything you write, subscribed or not, so a late subscriber can fetch the recent past. Capturing only while a producer's `demand()` reports a subscriber is an opt-in that saves the encode but leaves nothing from before the first one. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index b595ded6eb4..563210798c9 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -94,8 +94,9 @@ import ( // Publish encoded frames, or raw pixels with the codec inside the binding. // opusInit, packet, pts (90 kHz ticks), and rgba come from your encoder or capture source. -publication, _ := client.CreateBroadcast("my-stream.hang") -broadcast := publication.Broadcast() +// Hidden until the catalog is filled; keep the route reachable while publishing. +route, _ := client.Route("my-stream.hang", moq.RouteOptions{Hidden: true}) +broadcast, _ := route.Broadcast() audio, _ := moqmedia.NewAudioTrackProducer(broadcast, moqmedia.Named{}, moqmedia.AudioInit{Format: moqmedia.AudioFormatOpus, Data: opusInit}) timestamp := moq.Timestamp{Value: 960, Scale: 48_000} // 20 ms on the Opus sample clock _ = audio.WriteFrame(moq.Frame{Payload: packet, Timestamp: ×tamp}) @@ -109,7 +110,7 @@ video, _ := moqvideo.NewProducer(catalogProducer, moqvideo.ProducerOptions{ Track: &track, }) _ = video.Write(moqvideo.Frame{Timestamp: moq.Timestamp{Value: pts, Scale: 90_000}, Data: rgba}) -_ = publication.Announce(moq.AnnounceRoute{}) // each broadcast mints a fresh instance +_ = route.Announce() // each route mints a fresh instance (route.Instance()) _ = audio.Finish() _ = video.Finish() broadcast.Close() // keep the producer reachable while publishing, then close explicitly @@ -124,7 +125,7 @@ one to one; the API reference has the names, and [Layers](/lib/layers) shows which module holds each. - **Context cancellation.** Every call that can block takes a `context.Context` first, and cancelling it tears down the native work, not just your wait. A one-shot call (`Request`, `FetchGroup`, `Server.Accept`) aborts alone; a stream read (`Next`, `ReadFrame`, and the iterators over them) cancels the stream it reads. -- **Keep owners reachable.** An origin from `moq.NewOriginProducer` has no `Close`. It ends when the garbage collector reaches its last owner (each producer, published broadcast, and `OriginRequests`), and its consumers then fail with `moq.ErrClosed`. +- **Keep owners reachable.** An origin from `moq.NewOriginProducer` has no `Close`. It ends when the garbage collector reaches its last owner (each producer and `Route`), and its consumers then fail with `moq.ErrClosed`. A `Route` the collector reclaims is retracted, so keep it reachable while it should stay announced, or end it with `Cancel()`. - **Bandwidth ownership.** An encoder you manage can reserve a share of the session's bandwidth. Reuse its reservation when the ceiling changes; before abandoning or replacing it, call `Update(0)` to return its share and automatic probe ceiling without waiting for garbage collection. The track can stay subscribed and the reservation can be reused. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `Cut()`: after every frame, or less often to batch frames into fewer groups. - **Timeline breaks.** On a seek or pause, call `Discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. @@ -132,12 +133,12 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** `Close` each `VideoDecodedFrame` promptly, or the decoder stalls. `Resize` is best effort, so read each frame's `Width()` and `Height()`. - **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown()` requests that drain immediately; `session.Closed(ctx)` waits for its result, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `Finish()` on its `media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.Close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable` (`Internal` over moq-transport, which has no such code). Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). -- **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol. `Dial` returns a connecting session; read `Session().State()` and wait with `StateChanged(ctx, state.Revision)` to observe first-dial failures and reconnects. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Rate()` reads the current estimate in bits per second and `RateChanged(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`), and `DatagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol. `Dial` returns a connecting session; read `Session().State()` and wait with `StateChanged(ctx, state.Revision)` to observe first-dial failures and reconnects. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Rate()` reads the current estimate in bits per second and `RateChanged(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Viewers.** `Session().Viewers()` divides the connection's receive estimate among the broadcasts you watch, max-min fair by each one's ceiling. `Watch(maxBps)` returns a `Viewer` claiming up to what the largest rendition the player would switch to needs; `Rate()` reads its slice in bits per second, nil while there is no estimate, so hold the current selection. `Update(maxBps)` changes the ceiling, such as when the catalog enables or disables a rendition. Holding a viewer requests PROBE, and the session sends the viewers' summed ceilings as the PROBE target; `Close()` it once the broadcast stops being watched, from the goroutine that last used it. `SetTarget(&bps)` sends another target, zero sends none, and nil follows the viewers again. `Watch`, `Update`, and `SetTarget` return `moq.ErrOutOfRange` when the ceilings or the target would pass 2^53 - 1 bps, the most a PROBE target carries. Client viewers survive reconnects and read nil while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Grants()` returns a `GrantConsumer`: `Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects, and `Changed(ctx)` waits for the next one. `Close()` it when done. - **Tokens.** Set `ClientConfig.Tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `Session().Tokens().Set(...)` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `Answered(ctx)` returns nil once the relay accepts them, or its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `Reason` and structured protocol failures from `Details`. -- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Select an enabled rendition from the latest catalog before opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `Preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `moq.ErrUnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. +- **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `Preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `moq.ErrUnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** A zero `moq.Subscription{}`, a nil `*Subscription`, and `moq.DefaultSubscription()` all mean moq-net's defaults, whose zero `Delay` starts at the newest group and skips a superseded one at once. Set `Delay.MaxUnlimited` for no subscriber limit, and `Delay.MinUnstated` to leave the target latency unstated. Decoders take the same `moq.Delay`. - **Track demand.** Obtain a dynamic track request’s `Demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. A producer publishes everything you write, subscribed or not, so a late subscriber can fetch the recent past. Capturing only while a producer's `Demand()` reports a subscriber is an opt-in that saves the encode but leaves nothing from before the first one. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 69f64df188b..f8e77240783 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -29,19 +29,19 @@ const connection = await Moq.Connection.connect({ consume: origin, }); -const publication = origin.createBroadcast(Moq.Path.from("chat.room")); -const broadcast = publication.broadcast; -const track = broadcast.createTrack("messages", { timescale: Moq.Time.Timescale.MILLI }); +// Hidden until its first track exists, then announced. +const route = origin.route(Moq.Path.from("chat.room"), { hidden: true }); +const track = route.broadcast.createTrack("messages", { timescale: Moq.Time.Timescale.MILLI }); const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("hello"), timestamp: Moq.Time.Timestamp.now() }); group.finish(); -publication.announce(); +route.announce(); ``` -- **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A publication controls announcements; its `broadcast` fills tracks. The broadcast is invisible, locally and remotely, until the publication calls `announce()`. -- **Answer claimed paths with `using`.** `requests(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare()`, fill its broadcast, then `prepared.publish()` to answer with a new stream under a fresh instance and keep the `Publication` it returns. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned publication and its fill broadcast live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee. +- **The origin holds the routes, not the connection.** `origin.route(path)` creates a route that owns a new broadcast; fill its tracks through `route.broadcast`. Closing a session withdraws its announcements and leaves the routes for the next one. A route is visible at once unless created `{ hidden: true }`, which keeps it invisible, locally and remotely, until `announce()`. `withdraw()` hides it again and `announce()` brings the same stream back. `close()` retracts it and closes its broadcast, as closing the broadcast does. +- **Answer claimed paths with a route.** `origin.routePrefix(prefix)` claims a prefix, and `route.requested()` yields each requested path. Answer with `request.route()`, a new exact route under a fresh instance, or `request.route({ hidden: true })` to fill its broadcast before `announce()` answers the requesters; closing it or its broadcast before then refuses them as unroutable, so `using job = request.route({ hidden: true })` refuses on a throw. Serve a broadcast you already hold with `request.serve(broadcast)`, or refuse with `request.reject(err)`. - **End content by finishing tracks.** A broadcast has no finished state. When the input ends, `finish()` each track, then `close()` it; closing a track without finishing aborts it. The broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. -- **Requests can pin an instance.** `consumer.request(path, { instance })` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances). The resolved broadcast's `instance` names the stream it came through, pinned or not. A publication mints a random instance and keeps it across `announce()`; pass `announce({ instance, epoch })` only for a publisher that continues its group sequences across restarts and raises the epoch each time. +- **Requests can pin an instance.** `consumer.request(path, { instance })` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances). The resolved broadcast's `instance` names the stream it came through, pinned or not. A route mints a random instance when it is created and keeps it across `withdraw()` and `announce()`; pass `origin.route(path, { instance, epoch })` only for a publisher that continues its group sequences across restarts and raises the epoch each time. - **Follow restarts.** `origin.follow(path)` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close it when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same instance taking over (instances travel only on `moq-lite-07-wip`; older wires mint one per announcement) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends it. Pass `{ instance }` to pin one stream, which it waits for again after an end. `request(path)` instead keeps its accepted instance and ends once that instance stops serving; while pending, it fails `Unroutable` once discovery of its path has synced without a route for it. To list what is live, iterate `announced(scope)`. See [identity and recovery](/concept/identity) to choose between following and holding one instance. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)`, `request(path)`, or `follow(path)`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. Every request and follower watches its path from creation until closed, so it outlives the listing that found it. A relay that refuses an announce request downgrades that session: requests subscribe blind through it, and followers keep waiting for a session that can discover. - **Connections share by URL and versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream. @@ -58,7 +58,7 @@ publication.announce(); - **Subscription defaults.** `Track.Subscription.default()` returns the default subscription, whose zero `delay.max` skips a superseded group at once; calling `subscribe()` with no options means the same. An omitted `delay` field means the same zero; `max: null` sets no subscriber limit. - **A subscription asks for `ranges`.** Each [range](/concept/moq-lite#subscriptions) starts and ends at an `absolute` group, one `relative` to the latest, or the group containing a `time` (a `Timestamp`), and a reader reads only its own ranges. A read whose ranges all end finishes once every group in them is handed out, dropped, or skipped, as in Rust. A time anchor needs a timed track and `moq-lite-07-wip` over a session. Passing the old `groups` key throws a `TypeError` naming `ranges`; `withRange` only narrows reads locally. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Credential` it returns (`publication.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a route created with the `Auth.Credential` it returns (`origin.route(path, { auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; `route.setAuth(other)` renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. `moq-lite-05` and newer wait for the subscriber to receive finished tails; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: @@ -89,3 +89,48 @@ frame.finish(); const reader = await consumer.nextFrame(); for (let chunk = await reader?.readChunk(); chunk; chunk = await reader?.readChunk()) save(chunk); ``` + +## Sockets + +Use `Moq.socket.Endpoint` for [point-to-point sessions](/concept/socket), with the same +origin publishing and consuming through the relay. Each endpoint announces one hidden +broadcast; accepting verifies that the named peer actually dialed this session. +Every hop needs moq-lite-07 or moq-transport. Lite-07 currently requires explicitly +offering `moq-lite-07-wip`; older moq-lite hops do not carry these hidden endpoints. While +the endpoint's own client connection is moq-lite before 07, its dials and accepts fail as +unsupported instead of waiting. A dialer can't tell a peer behind such a hop from one not +online yet, so its dial waits as for any absent peer. + +```ts +const connection = await Moq.Connection.connect({ + url, versions: ["moq-lite-07-wip"], + publish: origin.consume(), consume: origin, +}); +const endpoint = new Moq.socket.Endpoint(origin, Moq.Path.from("game"), "viewer"); +const session = await endpoint.dialTcp("server", { signal: abort.signal }); +session.producer.writeFrame({ payload: new TextEncoder().encode("hello") }); +session.producer.finish(); // FIN in this direction; the peer may still reply. +for (let frame = await session.consumer.readFrame(); frame; frame = await session.consumer.readFrame()) { + receive(frame.payload); +} +endpoint.close(); +connection.close(); +``` + +A server takes `await endpoint.acceptTcp()`, then accepts or rejects that incoming dial. +The incoming peer and session are unverified claims; `accept()` can fail if the original +dial has closed. A pending dial waits for acceptance until it is cancelled or the endpoint +or session closes, with no acceptance timeout. +TCP and track dials have independent accept queues. For independent messages, use +`dialTrack` / `acceptTrack`: the stream exposes a track producer and an already subscribed +consumer in ascending order until updated. It skips groups that never arrive whole, +including refused, dropped, or evicted groups; a cut group is never a complete message. +TCP joins its one group live, recovering the head with a joining FETCH or fill over +moq-transport. Both stream types expose `peer` and +`session`; read and write through their producer and consumer handles. + +JS TCP currently reads without fastest-reader pacing. A reader that falls behind the +group's bounded tail fails with an eviction error, so keeping up is required for lossless +delivery. Finishing a producer is FIN; aborting, closing the endpoint, or releasing a TCP +reader early resets the session. Close endpoints explicitly, and use `AbortSignal` to +cancel pending dials and accepts. Keep session ids secret. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index aeff8755be2..1225a30d59c 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -31,7 +31,7 @@ WebCodecs, writes the catalog, and publishes a hang broadcast. | `url`, `name` | Relay URL (with `?jwt=` if needed) and broadcast name. | | `versions` | Protocol versions to offer, most preferred first, comma-separated like `moq-transport-18,moq-lite-06`. Every stable version by default. | | `source` | `camera`, `screen`, or `file`. | -| `muted`, `invisible` | Stop publishing audio or video. By default the microphone and camera stay open, so unmuting or unhiding is instant and the browser's capture indicators stay lit. | +| `muted`, `invisible` | Stop sending audio, or stop publishing video. A muted rendition stays in the catalog and its listeners stay subscribed, so the next word reaches them at once, as push-to-talk needs; they play the pause as comfort noise. By default the microphone and camera stay open, so unmuting or unhiding is instant and the browser's capture indicators stay lit. | | `capture` | What a muted or invisible track does with its device: `hold` (default) keeps it open, `release` closes it so its capture indicator goes dark, and resuming reopens it. A screen share or file stops once both tracks are off. Clearing `source` or removing the element releases everything either way. | | `preview` | What the nested element shows: the raw `source` (default), a decoded copy of the `encoded` stream to see what viewers get, or `none`. | | `backend` | How a `` preview is drawn; see [@moq/video](/lib/js/video). `auto` (default), `webgpu`, or `2d`. | @@ -74,17 +74,20 @@ with a warning, since every viewer would refuse it. Video encodes while enabled, so its recent groups wait in the track's cache for a late subscription's `delay.max` or a fetch. The cache keeps an idle group for 30 seconds whatever `maxAge` says; `maxAge` only bounds how far back a reader -may reach into it. Audio keeps raw PCM while nobody listens, then encodes that -history ahead of live audio when a subscriber arrives. Raw audio keeps up to +may reach into it. Audio keeps raw PCM while nobody listens, then encodes the part of +that history within the subscribers' max delay ahead of live audio when one arrives. Raw audio keeps up to `maxAge`, which for media defaults to 30 seconds (about 11.5 MB for 48 kHz stereo f32). With `maxAge: null`, which sets no publisher limit on the media, raw audio still keeps 30 seconds, since raw history needs a bound. The catalog and HLS record tracks keep `@moq/net`'s zero default, since a viewer needs only their newest group. Capture stays open. Disabling an audio encoder keeps what it retained: a subscriber still gets that history, and it expires on its own. Players skip a -disabled rendition, so that history reaches subscribers that are already held -on it or that subscribe directly. The codec stays -configured across the disable, so re-enabling resumes at once. +disabled rendition, so that history reaches only subscribers that ask for the +track directly. The codec stays configured across the disable, so re-enabling +resumes at once. To pause audio that comes back, such as push-to-talk, set the +encoder's `muted` instead: it sends nothing, keeps the rendition enabled, and +marks where each utterance ends, so listeners stay subscribed and treat the gap +as intentional silence. Pass `onDemand: false` to `Publish.Broadcast` to encode audio continuously too. `onDemand: true` also gates video, which then loses its unsubscribed past. diff --git a/doc/lib/js/room.md b/doc/lib/js/room.md index e97f4a7f367..e3c8c5dbd77 100644 --- a/doc/lib/js/room.md +++ b/doc/lib/js/room.md @@ -41,7 +41,9 @@ const room = new Room({ connection, identity: Path.from("alice") }); the camera's hd and sd renditions encode even when nobody in the room watches, so a member who arrives late can fetch the last few seconds. While `microphoneEnabled` is on, the microphone keeps raw audio and encodes it once -someone listens. Turn either off to stop that capture. +someone listens. Turn either off to stop that capture. Turning the microphone +off mutes it rather than removing it, so listeners stay subscribed and hear +the next word at once. On a public prefix, skip the token and dial that path directly. The conferencing demo at [`demo/web`](https://github.com/moq-dev/moq/tree/main/demo/web) (`meet.html`) diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index b2c524ba5ba..d0892470e57 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -72,8 +72,9 @@ Moq.connect("https://relay.example.com", ClientConfig(tls = ClientTls(roots = li // Publish encoded frames, or raw pixels with the codec inside the binding. // opusInit, packet, pts, and rgba come from your encoder or capture source. Moq.connect("https://relay.example.com").use { moq -> - val publication = moq.createBroadcast("my-stream.hang") - val broadcast = publication.broadcast() + // Hidden until the catalog is filled; hold the route while it should stay announced. + val route = moq.route("my-stream.hang", RouteOptions(hidden = true)) + val broadcast = route.broadcast() val audio = dev.moq.media.TrackProducer.audio(broadcast, Named(null), AudioInit(format = AudioFormat.OPUS, data = opusInit)) audio.writeFrame(Frame(payload = packet, timestamp = Timestamp(960u, 48_000u))) audio.cut() // audio has no keyframes, so this is what gives it groups @@ -84,7 +85,7 @@ Moq.connect("https://relay.example.com").use { moq -> output = VideoEncoderOutput(codec = VideoCodec.H264, bitrate = null, gop = null, kind = autoEncoder), track = "camera"), ) video.write(VideoFrame(timestamp = pts, data = rgba)) - publication.announce(AnnounceRoute()) // the broadcast mints its own instance + route.announce() // the route mints its own instance: route.instance() audio.finish() video.finish() broadcast.end() @@ -101,19 +102,19 @@ The rest of the [shared feature list](/lib/#what-every-binding-can-do) maps one to one; the API reference has the names, and [Layers](/lib/layers) shows which module holds each. -- **`end`, not `close`.** `broadcast.end()` ends a broadcast for good. `close()`, or leaving `use { }`, releases the handle, which ends the broadcast only once no other handle to it remains, such as its publication or a `requests()` handle. +- **`end`, not `close`.** `broadcast.end()` ends a broadcast for good, retracting its route. `close()`, or leaving `use { }`, releases the handle, which ends the broadcast only once no other handle to it remains, such as its route or a `requests()` handle. A route is the opposite: closing it retracts it at once, so hold it while the broadcast should stay announced. - **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `cut()`: after every frame, or less often to batch frames into fewer groups. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `dev.moq.media.TrackConsumer`, `dev.moq.audio.Consumer`, and `dev.moq.video.Consumer` return a `Read` in order: `ReadFrame`, a `ReadGap` where media was lost, or a `ReadEnd` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. A decoder reads everything the track still holds unless a live player sets `start = DecodeStart.LATEST` in its decoder output, which joins at the newest group and skips the cached backlog. - **Decoded frames hold decoder buffers.** `close()` each frame from a `dev.moq.video.Consumer` promptly (or `use { }` it), or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `moq.shutdown()` requests a drain immediately; suspending `moq.session.closed()` waits up to one second and reports its result. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.end()` takes it offline. How long to keep it up is yours to decide. A track still open at the end, or released without a finish, ends with `Unroutable` (`Internal` over moq-transport, which has no such code). Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). -- **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`), and `datagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Viewers.** `moq.viewers()` divides the connection's receive estimate among the broadcasts you watch, max-min fair by each one's ceiling. `watch(maxBps)` returns a `Viewer` claiming up to what the largest rendition the player would switch to needs; `rate()` reads its slice in bits per second, `null` while there is no estimate, so hold the current selection. `update(maxBps)` changes the ceiling, such as when the catalog enables or disables a rendition. Holding a viewer requests PROBE, and the session sends the viewers' summed ceilings as the PROBE target; `close()` it, or leave `use { }`, once the broadcast stops being watched. `setTarget(bps)` sends another target, `0` sends none, and `null` follows the viewers again. Ceilings or a target past 2^53 - 1 bps, the most a PROBE target carries, throw `MoqException.OutOfRange`. Client viewers survive reconnects and read `null` while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. Ending the collection cancels the consumer. - **Tokens.** Pass `ClientConfig(tokens = listOf(...))` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.tokens().set(listOf(...))` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. Suspending `tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. -- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Select an enabled rendition from the latest catalog before opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `MoqException.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. +- **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `MoqException.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** `Subscription.default()` returns moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once; passing no subscription means the same. `copy` it to change a field; building a `Subscription(...)` must name its `Delay(min, max)`, where a null `max` sets no subscriber limit and a null `min` leaves the target latency unstated. Decoders take the same `Delay`. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. A producer publishes everything you write, subscribed or not, so a late subscriber can fetch the recent past. Capturing only while a producer's `demand()` reports a subscriber is an opt-in that saves the encode but leaves nothing from before the first one. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 3d2fd2640a7..64d40be8805 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -71,8 +71,9 @@ import asyncio, moq async def main(): # opus_init_bytes, payload, pts, and rgba come from your encoder or capture source. async with moq.Client("https://cdn.moq.dev/anon") as client: - publication = client.create_broadcast("my-stream.hang") - broadcast = publication.broadcast + # Hidden until announced, so viewers find a filled catalog. Hold the route: dropping it retracts it. + route = client.route("my-stream.hang", hidden=True) + broadcast = route.broadcast # Already-encoded frames: the catalog is filled from the bitstream audio = moq.media.TrackProducer.audio(broadcast, moq.media.AudioInit(format=moq.media.AudioFormat.OPUS, data=opus_init_bytes)) @@ -99,7 +100,7 @@ async def main(): status = moq.json.SnapshotProducer(broadcast, status_track) status.update({"state": "live", "viewers": 42}, catalog_producer.now()) - publication.announce() # each broadcast mints a fresh instance + route.announce() # each route mints a fresh instance, route.instance audio.finish() video.finish() events.finish() @@ -124,12 +125,12 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** Drop each frame from a `moq.video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `session.shutdown()` requests a drain immediately; `await session.closed()` waits up to one second and reports its result. `cancel(code)` closes at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `moq.media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable` (`Internal` over moq-transport, which has no such code). Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). -- **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rate_changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`), and `datagrams_unrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rate_changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Viewers.** `session.viewers()` divides the receive estimate among the broadcasts you watch. `watch(max_bps)` returns a `Viewer` claiming up to a ceiling, the bitrate of the largest rendition you would switch to; its `rate()` reads that viewer's fair share in bits per second, `None` while there is no estimate (keep the current rendition), and `update(max_bps)` changes the ceiling. Holding a viewer requests the estimate like `recv_bandwidth()`, and dropping it releases the claim. The session sends the summed ceilings as its PROBE target; `set_target(bps)` overrides that, `0` sends none, and `None` follows the viewers again. Ceilings or a target past 2^53 - 1 bps, the most a PROBE target carries, raise `moq.Error.OutOfRange`. - **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `async for grant in session.grants()` yields the `Grant` the relay sent back and each change, `None` until it answers and while a client session reconnects. `grants().grant()` reads it once; cancelling or leaving an `async with` releases the wait. - **Tokens.** Pass `tokens=[...]` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `session.tokens().set([...])` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `await tokens.answered()` returns once the relay accepts them and raises its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. -- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Select an enabled rendition from the latest catalog before opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `moq.Error.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. +- **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `moq.Error.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** A bare `moq.Subscription()`, passing no subscription, and `moq.Subscription.default()` all mean moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once. Pass `delay=moq.Delay(max=None)` for no subscriber limit, or `min=None` to leave the target latency unstated. Decoders take the same `moq.Delay`. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. A producer publishes everything you write, subscribed or not, so a late subscriber can fetch the recent past. Capturing only while a producer's `demand()` reports a subscriber is an opt-in that saves the encode but leaves nothing from before the first one. @@ -155,3 +156,43 @@ A catalog that needs a newer moq-hang draft than this build reads raises `Error. - API source: [py/moq-rs/moq](https://github.com/moq-dev/moq/tree/main/py/moq-rs/moq) - Source and examples: [`py/moq-rs`](https://github.com/moq-dev/moq/tree/main/py/moq-rs) - Raw bindings: [`moq-ffi`](https://pypi.org/project/moq-ffi/) on PyPI, for the unwrapped API + +## Socket sessions + +`moq.socket` connects endpoints through relays in either direction. Wire one +origin to both session directions, then hold an endpoint while its sessions run: + +```python +import asyncio, moq + +async def socket_client(): + origin = moq.OriginProducer() + versions = ["moq-lite-07-wip"] + async with moq.Client("https://cdn.moq.dev/anon", versions=versions, publish=origin, consume=origin): + async with moq.socket.Endpoint(origin, "room", "client") as endpoint: + stream = await endpoint.dial_tcp("server") + stream.producer.write_frame(b"command") + stream.producer.finish() # FIN; abort(error_code) sends RST instead. + async for frame in stream.consumer: + print(frame.payload) + +asyncio.run(socket_client()) +``` + +Sockets need lite-07 or moq-transport on every hop, so the example opts into +`moq-lite-07-wip`. No client offers it by default while its wire format is a +work in progress, and the relay must accept it too. + +A server awaits `endpoint.accept_tcp()`, then calls `await incoming.accept()` +or `incoming.reject()`. Accepting verifies that the claimed peer really dialed. +`TcpStream` exposes group `producer` and `consumer` handles, plus `peer` and +`session`. Keep the session id private. Its bounded cache reports eviction if a +reader falls behind; it never skips bytes. + +Use `dial_track(peer, info=None)` and `accept_track()` for independent groups of +messages. `TrackStream` exposes a track producer and a reliably subscribed, +ascending consumer. Omitted `info` keeps messages without a publisher age limit; +update the consumer's subscription to change delivery. The protocols have +separate accept queues. Cancelling a dial or acceptance resets that dial; +closing the endpoint resets all its sessions. See [Sockets](/concept/socket) +for routing and authorization. diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index f373a8243cf..0e46fa6ac74 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -70,7 +70,7 @@ while let Some(event) = announced.next().await { // Skip retractions. A path stays present across publisher restarts: hold // `consumer.follow(path, None)` to be handed each new run. let moq_net::announce::Event::Present(update) = event else { continue }; - let broadcast = consumer.request(&update.prefix, update.route.instance).await?; + let broadcast = consumer.request(&update.prefix, Some(update.route.instance().clone())).await?; let catalog = broadcast .track(hang::Catalog::DEFAULT_NAME)? .subscribe(hang::Catalog::default_subscription()) @@ -80,12 +80,14 @@ while let Some(event) = announced.next().await { ``` ```rust -// Publish: create and announce a broadcast on the origin, then fill it. -let mut broadcast = origin.publish("my-stream.hang", Default::default())?; -// moq-mux (from a container) or moq-video / moq-audio (from a device) fill it. -// The route retracts on `unannounce()` or when the broadcast ends. To serve a whole -// subtree on demand instead, `origin.requests("room")?` yields each requested path: -// `prepare()`, fill its broadcast, then `publish()`, or reject it. +// Publish: create a route on the origin; it owns the broadcast to fill. +let route = origin.route("my-stream.hang", RouteOptions::default())?; +// moq-mux (from a container) or moq-video / moq-audio (from a device) fill +// `route.broadcast()`. Keep the route: dropping it, or closing its broadcast, +// retracts it, and `withdraw()`/`announce()` hide and show the same stream. To +// serve a whole subtree on demand instead, `origin.route_prefix("room", ..)?` +// yields each requested path: answer with `request.route(..)`, `serve` a held +// broadcast, or `reject`. ``` The connection and origin helpers spawn their drivers. Watch diff --git a/doc/lib/rs/moq-audio.md b/doc/lib/rs/moq-audio.md index 9d17eca0f06..a11d6b92068 100644 --- a/doc/lib/rs/moq-audio.md +++ b/doc/lib/rs/moq-audio.md @@ -35,8 +35,8 @@ position on that clock, so a reader that falls behind skips its own audio without stalling the mix. Without a subscriber the encoder keeps raw PCM -up to the track's `max_age`, then encodes that history before continuing live when -someone subscribes. With an unlimited `max_age` it keeps at most 30 seconds +up to the track's `max_age`, then encodes the part of that history within the +subscribers' max delay before continuing live when someone subscribes. With an unlimited `max_age` it keeps at most 30 seconds (about 11.5 MB for 48 kHz stereo f32). Set `encode::Capture::on_demand` to false to encode continuously instead. Stopping an encoder leaves the shared capture running, and the history it retained stays on the track until it is subscribed or expires; a device failure keeps diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 634ebe17553..90c8bc15e34 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -15,15 +15,15 @@ moq-transport at setup and presents one API either way. Media is a layer above What you use it for, beyond what the concept page already describes: -- **An origin outlives the session.** Creating a broadcast returns an origin publication that controls its announcement and exposes the broadcast to fill. Standalone and Prepared fill producers cannot announce; only publishing the Prepared answer admits its publication. A reconnect announces existing publications again, and closing the session does not delete them. -- **Requests can pin an instance.** `consumer.request(path, Some(instance))` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances); `None` takes whichever route wins. The resolved consumer names its instance in `info().instance`, pinned or not. A broadcast announced with `Route::default()` serves a random instance it keeps across re-announcing; set one with `Route::with_instance(instance, epoch)` only for a publisher that continues its group sequences across restarts and raises the epoch each time. +- **An origin outlives the session.** `origin.route(path, options)` returns a `Route` that owns the broadcast to fill (`route.broadcast()`) and controls its announcement: `announce()`, `withdraw()`, `set_cost()`, and dropping the last clone retracts it. `origin.route_prefix(prefix, options)` serves requests beneath a prefix instead, each answered with `request.serve(held)`, `request.route(options)` (the path's own exact route) or `reject`. A standalone broadcast cannot announce; publish one only through a prefix route's `serve`. A reconnect announces existing routes again, and closing the session does not delete them. +- **Requests can pin an instance.** `consumer.request(path, Some(instance))` resolves only through a route announcing that [publisher instance](/concept/moq-lite#publisher-instances); `None` takes whichever route wins. The resolved consumer names its instance in `info().instance`, pinned or not. A route serves a random instance minted when it is created and kept across `withdraw()` and `announce()`; set one with `RouteOptions::with_instance(instance, epoch)` only for a publisher that continues its group sequences across restarts and raises the epoch each time. `route.instance()` names it. - **Rank several CDNs.** Dial each with its own subscribe handle, `origin.clone().with_preference(origin::Preference::new(rank))` (higher wins; 0 is neutral and a negative rank demotes), all on one origin. Requests use the preferred link that has a route and fail over to the next; see [multiple CDNs](/concept/moq-lite#multiple-cdns). - **Request one instance, or follow them all.** `consumer.request(path, None)` resolves the instance serving a path now, and fails `Unroutable` only once discovery has synced with no route for it. `consumer.follow(path, None)` hands out each publisher instance serving a path from `available()`: the first once announced, then each one that takes over, `None` once the path goes offline, and whatever serves it next, like JS `follow(path)`. `consumer.announced()` lists paths only as present or absent. A broadcast already resolved stays on the old instance until dropped or its route goes. See [identity and recovery](/concept/identity) to choose between following and holding one instance. - **Discovery follows what you watch.** A client session asks the relay for announcements only under the prefixes its subscribe origin's readers watch, with `announced()`, `request`, or `follow`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A broadcast resolved through a discovered route keeps its path watched until dropped. `request` owns discovery of its path, so no separate watch is needed. A server session, or a client fed into an origin handle marked `with_link(origin::Link::Peer)` or `Link::Upstream`, asks for everything it may, so a relay discovers everything. An HTTP gateway (the moq-hls `Server`, the moq-rtc WHEP router) watches its whole scope while it lives, so the paths its callers name never open a request each. moq-lite-03/04 sessions, whose announcements mark no initial set, still ask for everything. - **Dial and accept sockets.** `socket::Endpoint::new(&origin, prefix, id)` announces a hidden endpoint at `/.socket/`, used only to route dials. A session speaks one of two protocols, each with its own dial and accept. `accept_tcp()` and `accept_track()` yield an `Incoming` dial to `accept` or `reject` (dropping it rejects). `dial_tcp(peer)` and an accepted `tcp` dial yield a `socket::TcpStream`, a `group::Producer` and `group::Consumer` pair carrying one reliable, ordered byte pipe each way (finish is FIN, abort is RST, and `ready` paces the writer to the reader); `dial_track(peer, info)` and an accepted `track` dial yield a `socket::TrackStream`, this side's `track::Producer` and a `track::Subscriber` of the peer's half, subscribed reliably and in order until the application updates it, one message per group. A dial resolves once the peer accepts, and an accept checks that the claimed dialer really dialed this endpoint, waiting for its endpoint while the dial stays open. Every hop needs moq-lite-07 or moq-transport: while the origin has an older moq-lite client session whose publish half doesn't opt into hidden broadcasts (`with_hidden(true)`), dials and accepts fail with `Unsupported`. A dial to a peer behind such a hop waits, as for a peer not online yet. Session tracks are never announced or listed. See [Sockets](/concept/socket). - **One track per name.** Concurrent subscriptions share one request and producer. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a producer that replaces it starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups with `create_group` at an explicit sequence. Readers of an aborted track resume onto the replacement when they read through an origin request, whose route always names an instance; otherwise their subscription ends with the error. - **Authored tracks default to a zero `max_age`.** `track::Info::default()` sets `max_age` to `Some(Duration::ZERO)`, the live edge only; a publisher that wants readers to reach history sets a longer bound, or `with_max_age(None)` for no publisher limit. Media tracks from `hang::container::track_info` declare `hang::container::MAX_AGE` (30 seconds). `max_age` is how long a superseded group stays fresh, never the newest group; it frees no bytes, which the cache pool's expiry reclaims. Received metadata keeps absence as no limit, and an origin's `cache_duration` can only cap it lower. -- **End content by finishing tracks.** A broadcast has no finished state. When the input ends, finish each track; the broadcast stays announced and keeps serving them until you close it or drop its last producer. How long to keep it up for late readers is yours to decide: `moq_mux::Linger` holds a hang broadcast for a fixed window. +- **End content by finishing tracks.** A broadcast has no finished state. When the input ends, finish each track; the broadcast stays announced and keeps serving them until you close it or drop its route. How long to keep it up for late readers is yours to decide: `moq_mux::Linger` holds a hang broadcast for a fixed window. - **Demand says whether anyone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached. First-party capture does not wait for it: video encodes while enabled and audio keeps raw PCM to encode when someone listens, so a late subscription's `delay.max` or a FETCH finds the recent past. Demand gates bandwidth reservations, transcode rungs, and on-demand video where a publisher opts in, which saves the encode but leaves nothing from before the first subscriber. Holding a broadcast or track consumer is not demand: a track consumer requests nothing until it queries, subscribes, or fetches, and those count until dropped. A shared fetch stays up until its last reader leaves. Demand rolls up: a frame being read keeps its group wanted, a group a subscription or fetch handed out keeps its track wanted until read to its end, and any wanted track keeps its broadcast wanted. - **Use the model without a runtime.** By default `moq-net` is its `poll_*` APIs and origin driver with caller-supplied time. The `async` feature adds their async wrappers, `time::run`, and both wire sessions. - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio. `moq-tokio` hides that. A custom transport implements `moq_net::transport::poll`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 4cf0183aee2..7889493d3f4 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -70,8 +70,9 @@ for try await event in try session.consume.announced(prefix: "live/", filter: "* ```swift // Publish encoded frames, or raw pixels with the codec inside the binding (VideoToolbox). // opusInit, packet, pts, and rgba come from your encoder or capture source. -let publication = try session.publish.createBroadcast(path: "my-stream.hang") -let broadcast = publication.broadcast +// Hidden until the catalog is filled; hold the route while it should stay announced. +let route = try session.publish.route(path: "my-stream.hang", hidden: true) +let broadcast = try route.broadcast let audio = try Media.TrackProducer.audio(broadcast: broadcast, initData: Media.AudioInit(format: .opus, data: opusInit)) try audio.writeFrame(packet, timestamp: Timestamp(value: 20, scale: 1_000)) try audio.cut() // audio has no keyframes, so this is what gives it groups @@ -83,7 +84,7 @@ let video = try Video.Producer( track: "camera" ) try video.write(VideoFrame(timestamp: pts, data: rgba)) -try publication.announce(route: AnnounceRoute()) // the broadcast mints its own instance +try route.announce() // the route mints its own instance: route.instance try audio.finish() try video.finish() try broadcast.close() @@ -105,13 +106,13 @@ shows which module holds each. - **Media reads.** `Media.TrackConsumer`, `Audio.Consumer`, and `Video.Consumer` yield `Read` cases in order: `.frame`, a `.gap` where media was lost, or an `.end` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. A decoder reads everything the track still holds unless a live player sets `start: .latest` in its decoder output, which joins at the newest group and skips the cached backlog. - **Decoded frames hold decoder buffers.** Release each frame from a `Video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `session.shutdown()` requests a drain immediately; `try await session.closed()` waits up to one second and reports its result. `session.cancel(code:)` closes at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. -- **Ending cleanly.** When the input ends, finish every track, then call `try finish()` on its `Media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable` (`Internal` over moq-transport, which has no such code). Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). -- **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Ending cleanly.** When the input ends, finish every track, then call `try finish()` on its `Media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()`, or releasing its route, takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable` (`Internal` over moq-transport, which has no such code). Readers follow a publisher's runs by announcement, not by that error: see [identity and recovery](/concept/identity). +- **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`), and `datagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Viewers.** `session.viewers()` divides the receive estimate among the broadcasts you watch. `watch(maxBps:)` returns a `Viewer` claiming up to a ceiling, the bitrate of the largest rendition you would switch to; its `rate()` reads that viewer's fair share in bits per second, `nil` while there is no estimate (keep the current rendition), and `update(maxBps:)` changes the ceiling. Holding a viewer requests the estimate like `recvBandwidth()`, and releasing it releases the claim. The session sends the summed ceilings as its PROBE target; `setTarget(targetBps:)` overrides that, `0` sends none, and `nil` follows the viewers again. `watch`, `update`, and `setTarget` throw `MoqError.OutOfRange` when the ceilings or the target would pass 2^53 - 1 bps, the most a PROBE target carries. - **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `for try await grant in session.grants()` yields the `Grant` the relay sent back and each change, `nil` until it answers and while a client session reconnects. Cancelling the iterating task cancels the native wait. - **Tokens.** Pass `Client(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `try session.tokens()?.set([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `try await tokens.answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Diagnostic cases use the `reason:` argument label; match their associated value to inspect it. Protocol errors carry `details`. -- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Select an enabled rendition from the latest catalog before opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `MoqError.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. +- **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. Audio and video subscribe setup returns `MoqError.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** `Subscription.default` holds moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once; passing no subscription means the same. Copy it to change a field; building a `Subscription(...)` must name its `Delay(min:max:)`, where a `nil` `max` sets no subscriber limit and a `nil` `min` leaves the target latency unstated. Decoders take the same `Delay`. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. A producer publishes everything you write, subscribed or not, so a late subscriber can fetch the recent past. Capturing only while a producer's `demand()` reports a subscriber is an opt-in that saves the encode but leaves nothing from before the first one. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index c8cf35e9acd..fa80b68944b 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -156,7 +156,7 @@ type Catalog = { "text": TextSchema | undefined, "json": JsonTracks | undefined, "binary": BinaryTracks | undefined, - "contributions": Record | undefined, @@ -172,7 +172,7 @@ For example, a chat entry should name a chat track, not carry individual chat me This way catalog updates are rare and a client MAY choose to not subscribe. The `json` and `binary` sections ({{data}}) are how a track like that is listed. -This specification defines audio, video, and text media tracks, an optional `media` section ({{media}}) holding the clock that maps content time to wall time and the interval a viewer can join on, the root `version` ({{catalog-version}}), the application data tracks in {{data}}, optional publisher statistics and solicited viewer feedback ({{media-stats}}), and whole broadcasts listed as `contributions` ({{contributions}}). +This specification defines audio, video, and text media tracks, an optional `media` section ({{media}}) holding the clock that maps content time to wall time and the interval a viewer can join on, the root `version` ({{catalog-version}}), the application data tracks in {{data}}, optional publisher statistics and solicited viewer feedback ({{media-stats}}), and whole broadcasts listed in `include` ({{include}}). ## Version {#catalog-version} The root `version` field is the oldest draft of this specification that reads the catalog correctly, by draft number: `4` needs a consumer of moq-hang-04 or later. @@ -188,7 +188,7 @@ This draft needs `version` 4 for: - a `track` ({{field-track}}) other than the entry's ID, which an older consumer subscribes to instead; - a video `warmup` ({{field-warmup}}) above zero, since an older consumer starts decoding a group at a picture that is not a keyframe. -Every other catalog change in this draft is safe to ignore: `enabled` ({{field-enabled}}) leaves an older consumer selecting a disabled rendition, which plays nothing; `media` ({{media}}) only offers the clock and join interval, which an older consumer goes without; `stats` and `echo` ({{media-stats}}) only offer optional tracks; `contributions` ({{contributions}}) only offer broadcasts to open on selection; video `reorder` leaves an older consumer inferring decode times from timestamps; `jitter`, `delay`, audio `warmup`, and the data track estimates are hints. +Every other catalog change in this draft is safe to ignore: `enabled` ({{field-enabled}}) leaves an older consumer selecting a disabled rendition, which plays nothing; `media` ({{media}}) only offers the clock and join interval, which an older consumer goes without; `stats` and `echo` ({{media-stats}}) only offer optional tracks; `include` ({{include}}) only offers broadcasts to open on selection; video `reorder` leaves an older consumer inferring decode times from timestamps; `jitter`, `delay`, audio `warmup`, and the data track estimates are hints. ## Media {#media} The catalog's root `media` field holds what applies to every audio and video track: @@ -238,22 +238,25 @@ A group longer than the interval is an overrun, which an edge tolerates. A consumer that cannot decode `join`, including a zero `interval`, MUST treat it as absent. Nesting the interval under `join` leaves room for a later join mechanism that is not a fixed grid. -## Contributions {#contributions} -The optional root `contributions` map names whole broadcasts, such as each guest feed in a composed room, whose own catalogs describe their renditions. -Each key is a contribution ID. +## Includes {#include} +The optional root `include` map names whole broadcasts, such as transcoded renditions of the source, whose own catalogs describe their renditions. +Each key is an include ID. Each entry MUST contain a string `broadcast`, resolved against the catalog's broadcast as in {{field-broadcast}}, and MAY contain an `instance` as in {{field-instance}}. A consumer MUST reject a catalog with a malformed entry or a reference that escapes above the root. -Listing an entry MUST NOT subscribe to the contribution: a consumer opens its catalog only when it selects the contribution or one of its renditions. -An HLS master selects every contribution it lists, reading their catalogs to describe their renditions, but MUST NOT subscribe to their media until requested by a player. -It segments a contribution's renditions on the listing catalog's `media` ({{media}}), whatever the contribution's own catalog declares. +An include MUST share the listing catalog's PTS timeline and root settings, including its `media.clock` and `media.join.interval` ({{media}}). +A consumer uses the listing catalog's settings without comparing the include's own settings; a mismatch is a publisher error. + +Listing an entry MUST NOT subscribe to the include: a consumer opens its catalog only when it selects the include or one of its renditions. +An HLS master selects every include it lists, reading their catalogs to describe their renditions, but MUST NOT subscribe to their media until requested by a player. +It segments an include's renditions on the listing catalog's `media` ({{media}}), whatever the include's own catalog declares. A publisher producing media on demand MUST answer its catalog without starting media production. -A selected contribution resolves like a rendition's reference, under the same rules for `instance` and restarts ({{field-instance}}). -A contribution produced on demand has no stream until it resolves, so a publisher omits its `instance` until then. +A selected include resolves like a rendition's reference, under the same rules for `instance` and restarts ({{field-instance}}). +An include produced on demand has no stream until it resolves, so a publisher omits its `instance` until then. -A contribution's rendition IDs belong to its own catalog and MUST NOT replace IDs in the listing catalog, so the same ID MAY name a contribution, a rendition in the listing catalog, and a rendition in the contribution. -An unavailable contribution does not invalidate the listing catalog. -A consumer does not expand a contribution's own `contributions`: each needs a selection of its own. +An include's rendition IDs belong to its own catalog and MUST NOT replace IDs in the listing catalog, so the same ID MAY name an include, a rendition in the listing catalog, and a rendition in the include. +An unavailable include does not invalidate the listing catalog. +A consumer does not expand an include's own `include`: each needs a selection of its own. ## Track Identity {#identity} A track name always means the same content, so a catalog update never redefines an audio or video track. @@ -826,8 +829,7 @@ For example, with video at 20 millisecond intervals and audio at 15 written as v ### enabled {#field-enabled} The `enabled` field says whether a consumer may select an audio or video rendition; a text rendition does not carry it. -When `enabled` is false, no frames are coming, and a consumer SHOULD NOT select the rendition. -A consumer MAY keep a rendition it already selected while that rendition is disabled and no enabled one it can decode remains, so a re-enable needs no new subscription. +When `enabled` is false, no frames are coming, and a consumer MUST NOT select the rendition. If absent, `enabled` defaults to true; a publisher SHOULD only write it when false. Earlier versions defined a video `stalled` field instead. @@ -1096,7 +1098,7 @@ the broadcast path, not the payload. This track is distinct from an application' `hang/chat.json` snapshot extension. # Security Considerations -A rendition's `broadcast` reference ({{field-broadcast}}), like a contribution's ({{contributions}}), resolves against the consumer's root, which is the subtree it is authorized for. +A rendition's `broadcast` reference ({{field-broadcast}}), like an include's ({{include}}), resolves against the consumer's root, which is the subtree it is authorized for. Clamping a reference that escapes above that root would silently redirect the subscription to an unrelated broadcast, so a consumer rejects the catalog instead. TODO Security @@ -1127,8 +1129,9 @@ This document has no IANA actions. - Added the optional video `preference` field: a consumer selects automatically among the highest preference it can decode. - Added the optional video `reorder` field: the most frames that can precede one in decode order and follow it in presentation, fixed for the track once declared. A consumer that authors decode times derives them from it and the current group alone, refusing deeper reordering; without it, a consumer infers them from timestamps. - Added an optional `instance` beside every rendition and data track `broadcast` reference ({{field-instance}}). A consumer reads each referenced path from the first broadcast it resolves for a catalog instance; a present `instance` fails a rendition that resolves another stream, and is best effort against an Instance a receiver minted. -- Added the optional root `contributions` map ({{contributions}}): whole broadcasts named by a relative `broadcast` and an optional `instance`, opened only on selection, whose rendition IDs stay their own. -- An HLS master selects the contributions it lists by reading their catalogs, without demanding media, and segments them on the listing catalog's `media`; an on-demand publisher answers its catalog without starting media production. +- Added the optional root `include` map ({{include}}): whole broadcasts named by a relative `broadcast` and an optional `instance`, opened only on selection, whose rendition IDs stay their own. +- Includes share the listing catalog's PTS timeline and root settings, including its clock and join interval. +- An HLS master selects the includes it lists by reading their catalogs, without demanding media, and segments them on the listing catalog's `media`; an on-demand publisher answers its catalog without starting media production. - Added optional `stats` and `echo` catalog sections, publisher/viewer snapshots keyed by rendition ID, and the `.echo` feedback broadcast convention. - An Opus frame timestamp counts the OpusHead pre-skip: a consumer presents each decoded sample the pre-skip earlier than its frame timestamp places it. @@ -1144,7 +1147,7 @@ This document has no IANA actions. - Added the optional `delay` rendition field: how far a rendition's timestamps trail those other renditions already wrote, never lowered once advertised and never subtracted across renditions. - Recommended namespaced keys for application root sections. - Added the optional `delay` field to `json` and `binary` track entries, measured only from payloads stamped with their capture time on the broadcast clock. -- Replaced the video `stalled` field with an optional `enabled` field on audio and video renditions. A consumer SHOULD NOT select a disabled rendition, MAY keep one it already selected while no enabled alternative remains, and ignores `stalled`. A consumer that predates `enabled` keeps selecting a disabled rendition. +- Replaced the video `stalled` field with an optional `enabled` field on audio and video renditions. A consumer MUST NOT select a disabled rendition and ignores `stalled`. A consumer that predates `enabled` keeps selecting a disabled rendition. - Audio `sampleRate` and `numberOfChannels` describe the decoded output, so HE-AAC names its SBR rate and parametric stereo names two channels. - Added an optional `track` field to video, audio, text, `json`, and `binary` entries naming the track in its broadcast; the entry's key is its ID and names the track when `track` is absent. A consumer MUST reject a catalog using one ID for both a video and an audio rendition. - Defined video `displayAspectWidth` and `displayAspectHeight` as the whole picture's display aspect, not the sample aspect. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 544b7c04030..246d26ddd5a 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -19,6 +19,7 @@ author: normative: moqt: I-D.ietf-moq-transport qmux: I-D.ietf-quic-qmux + webtrans-h3: I-D.ietf-webtrans-http3 qmuxws: title: "QMux over WebSocket" target: https://datatracker.ietf.org/doc/draft-lcurley-qmux-websocket/ @@ -29,12 +30,15 @@ normative: date: false RFC3986: RFC6455: + RFC8032: + RFC8446: RFC8831: RFC9000: RFC9002: informative: I-D.lcurley-moq-cluster: + RFC8966: moqflate: title: "DEFLATE Compressed Tracks for MoQ" target: https://datatracker.ietf.org/doc/draft-lcurley-moq-flate/ @@ -131,16 +135,16 @@ A Broadcast is a collection of Tracks named by a path. This corresponds to a MoqTransport's "track namespace". A path is a sequence of non-empty segments joined by `/` (0x2F), so it never starts or ends with `/` or contains two in a row; the empty path has no segments and prefixes every path. A `\` (0x5C) escapes the `\` or `/` that follows it, as a MoqTransport namespace field holding either is spelled; a path with any other `\` is a PROTOCOL_VIOLATION. -A continuous stream is named by an Instance, carried on its routes (see [ANNOUNCE_START](#announce-start)): a path and an Instance name one Broadcast, and a publisher MUST NOT serve different content under the same pair. -Within one Instance, group sequences never restart and never name two different groups, so routes sharing it serve the same bytes; two Instances are independent streams, never stitched together. +A continuous stream is named by an Instance, carried on its claims (see [ANNOUNCE_START](#announce-start)): a path and an Instance name one Broadcast, and a publisher MUST NOT serve different content under the same pair. +Within one Instance, group sequences never restart and never name two different groups, so claims sharing it serve the same bytes; two Instances are independent streams, never stitched together. A publisher that starts its group sequences over, such as a restarted process, uses a new Instance, while replicas of the same content share one. A publisher MAY keep an Instance across restarts only if it continues the sequence space and announces a higher Epoch each time. -The Epoch is a counter within one Instance that picks its live incarnation while a stale route lingers; it is never compared across Instances. +The Epoch is a counter within one Instance that picks its live incarnation while a stale claim lingers; it is never compared across Instances. -A publisher advertises what it can serve via ANNOUNCE_START messages, each carrying a path prefix: a route covering every broadcast path beneath it. -A route is the only shape an advertisement takes: a publisher that serves only some of the paths beneath a prefix, such as a transcoder for any broadcast's derivative, advertises the covering prefix and refuses the requests it will not serve (see [Resolution](#resolution)); no message narrows a route. -The subscriber uses the ANNOUNCE_REQUEST message to discover these routes, then subscribes to specific paths under them; which paths name broadcasts is an application convention. -The common convention is that a publisher announces each broadcast's exact path as its own route, so subscribers can enumerate broadcasts, while a service announces one short prefix and serves whatever is requested beneath it. +A publisher advertises what it can serve via ANNOUNCE_START messages, each carrying a path prefix: a claim covering every broadcast path beneath it. +A claim is the only shape an advertisement takes: a publisher that serves only some of the paths beneath a prefix, such as a transcoder for any broadcast's derivative, advertises the covering prefix and refuses the requests it will not serve (see [Resolution](#resolution)); no message narrows a claim. +The subscriber uses the ANNOUNCE_REQUEST message to discover these claims, then subscribes to specific paths under them; which paths name broadcasts is an application convention. +The common convention is that a publisher announces each broadcast's exact path as its own claim, so subscribers can enumerate broadcasts, while a service announces one short prefix and serves whatever is requested beneath it. Announcements are live and can change over time, allowing for dynamic origin discovery. A broadcast consists of any number of Tracks. @@ -392,113 +396,157 @@ There's a 1-byte STREAM_TYPE at the beginning of each stream. | ------- | ------------- | ----------- | ### Announce -A subscriber can open an Announce Stream to discover routes matching a prefix. -A route is an advertisement that paths under its prefix can be served; it claims capability, not inventory, so it never asserts that any specific broadcast exists. +A subscriber can open an Announce Stream to discover claims matching a prefix. +A claim is an announcement that paths under its prefix can be served by one origin node; it claims capability, not inventory, so it never asserts that any specific broadcast exists. +A route is how the publisher reaches an origin node, and every claim on the stream travels under the route to its origin (see [Routing](#routing)). The subscriber creates the stream with an ANNOUNCE_REQUEST message. -The publisher replies with a single ANNOUNCE_OK message followed by announcements for any matching routes and any future changes: - -- ANNOUNCE_START: a matching route over a path prefix is available. -- ANNOUNCE_END: a previously started route is no longer advertised. -- ANNOUNCE_UPDATE: a previously started advertisement was atomically updated (new hops or cost). -- ANNOUNCE_RESTART: a previously started advertisement now serves another Instance, or a higher Epoch of the same one. -- ANNOUNCE_TAKEOVER: a matching route is available and continues what a previously started advertisement served beneath it. +The publisher replies with a single ANNOUNCE_OK message followed by the matching routes and claims and any future changes: + +- ROUTE_START: the publisher reaches an origin node. +- ROUTE_UPDATE: a route's seqno, metric, or down-only bit changed. +- ROUTE_HOLD: the publisher has no usable route but holds it while it recovers. +- ROUTE_END: the publisher let go of a route and every claim under it. +- ANNOUNCE_START: a matching claim of a route's origin node. +- ANNOUNCE_END: the origin withdrew a claim. +- ANNOUNCE_UPDATE: a claim's cost changed. +- ANNOUNCE_RESTART: a claim now serves another Instance, or a higher Epoch of the same one. +- ANNOUNCE_TAKEOVER: a matching claim continues what a live claim served beneath it. +- ANNOUNCE_FLOOR: a batch of a route's claims is complete. - SYNCED: the initial set is complete. -ANNOUNCE_OK carries metadata that applies to every announcement on the stream: the publisher's own `Hop ID` (the implicit trailing entry of every announcement's path) (see [ANNOUNCE_OK](#announce-ok)). +In the other direction, the subscriber sends ROUTE_REQUEST and echoes every ROUTE_END (see [Routing](#routing)). + The publisher MUST send [SYNCED](#synced) exactly once, after the initial set. -The subscriber MAY batch announcements until SYNCED; subsequent announcements are live updates. +The subscriber MAY batch messages until SYNCED; subsequent ones are live updates. The initial set is relative to this hop: a relay answers with what it holds without waiting for upstreams. -The publisher MAY coalesce changes to a route it has not yet sent, and sends no more events before SYNCED than the initial set holds routes. -A retraction of a route already sent MAY precede SYNCED. +The publisher MAY coalesce changes to a claim it has not yet sent, and sends no more claims before SYNCED than the initial set holds. +A retraction of a claim or route already sent MAY precede SYNCED. -Each ANNOUNCE_START or ANNOUNCE_TAKEOVER implicitly assigns the next Announce ID on the stream: a counter starting at 0 that increments by 1 per advertisement. -The id never appears on the wire; both endpoints derive it from the message order on the (reliable, ordered) stream. -ANNOUNCE_END, ANNOUNCE_UPDATE, ANNOUNCE_RESTART, and ANNOUNCE_TAKEOVER reference an Announce ID instead of repeating the route's prefix. +Each ROUTE_START implicitly assigns the next Route ID on the stream, and each ANNOUNCE_START or ANNOUNCE_TAKEOVER the next Announce ID: two counters starting at 0 that increment by 1 per assignment. +Neither id appears on the wire where it is assigned; both endpoints derive it from the message order on the (reliable, ordered) stream, and other messages reference it instead of repeating the origin node or the claim's prefix. +Ids are never reused within a stream. +A ROUTE_END retires its Route ID and the Announce ID of every claim under the route; an ANNOUNCE_END retires one Announce ID. -Each route has at most one current advertisement per stream. -A second ANNOUNCE_START for an already-advertised route is a protocol violation; an ANNOUNCE_UPDATE or ANNOUNCE_RESTART atomically replaces the current advertisement while keeping its id live, and an ANNOUNCE_TAKEOVER at its predecessor's own prefix replaces it under a new id. +A stream has at most one live route per origin node, and each route at most one live claim per prefix. +Another origin claiming the same prefix is another claim, under its own route. +An ANNOUNCE_UPDATE or ANNOUNCE_RESTART atomically replaces a claim while keeping its id live, and an ANNOUNCE_TAKEOVER at its predecessor's own prefix replaces it under a new id; a claim never moves to another route. -The subscriber MUST close the session with a PROTOCOL_VIOLATION if it receives an ANNOUNCE_END, ANNOUNCE_UPDATE, ANNOUNCE_RESTART, or ANNOUNCE_TAKEOVER referencing an Announce ID that was never assigned or already retired, an ANNOUNCE_START or ANNOUNCE_TAKEOVER for a route that is already advertised other than as its predecessor, or any announcement before ANNOUNCE_OK. -When the stream is closed, the subscriber MUST assume that all routes are now unavailable. +The subscriber MUST close the session with a PROTOCOL_VIOLATION if it receives a message referencing a Route ID or Announce ID that was never assigned or already retired, a ROUTE_START for an origin node that already has a live route on the stream, an ANNOUNCE_START or ANNOUNCE_TAKEOVER for a prefix its route already claims other than as its predecessor, or any message before ANNOUNCE_OK. +When the stream is closed, the subscriber MUST assume that every route and claim on it is gone. A broadcast path has at most 32 segments and at most 4,096 bytes; a receiver MUST close the session with a PROTOCOL_VIOLATION on a longer one, including one resolved by [Compression](#announce-compression). -A route covers a path when its prefix is a leading run of the path's segments; matching is per path segment, so a prefix never matches half a segment, and equality is byte-by-byte within each segment. -A publisher answering a request stream presents each of its routes clamped to the intersection with the requested prefix: a route above the request's prefix appears as the request prefix itself (an empty suffix), which is exactly the covered set the subscriber may see. -There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE_OK + announcements. +A claim covers a path when its prefix is a leading run of the path's segments; matching is per path segment, so a prefix never matches half a segment, and equality is byte-by-byte within each segment. +A publisher answering a request stream presents, per route, each claim inside the requested prefix and only the most specific claim at or above it, at the requested prefix itself (an empty suffix), which is exactly the covered set the subscriber may see. +When that most specific covering claim changes, such as when it is withdrawn and a broader one still covers the request, the publisher moves the presented claim to the new one with an ANNOUNCE_UPDATE or ANNOUNCE_RESTART, and ends it only when no claim of the route covers the requested prefix. +There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE_OK and messages. + +#### Floors {#floors} +Each origin node numbers the changes to its own claims with a Revision, starting at 0 for each Node ID. +A stream carries a route's claims in batches: the ANNOUNCE messages under the route since its last ANNOUNCE_FLOOR, closed by the next one. +The publisher sends a ROUTE_START before any claim under it, and SYNCED only once every route it sent has a floor. + +A floor names a Revision and certifies that the stream's claims for the route, across the whole requested prefix, are the origin's claims at that Revision as the request presents them (see [Announce](#announce)). +It certifies only what its own request shows: a stream whose ANNOUNCE_REQUEST did not set `Hidden` certifies nothing about hidden claims. +A publisher that does not know the route's claims across the whole requested prefix at one Revision, such as a relay that knows them only beneath part of it, still closes each batch, with a floor that certifies nothing. + +The subscriber applies a batch whole, once its floor arrives. +A receiver that hears one origin's claims on several streams merges them claim by claim, each at the prefix its stream presents it: of the streams whose floor certifies the claim, the one with the highest Revision decides whether it exists, and a claim no floor certifies exists while some stream carries it. +An older floor never revives a claim whose withdrawal a newer one delivered. +A floor that certifies nothing proves no claim absent: the stream's claims stay until its ANNOUNCE_END or ROUTE_END. +While a receiver has a usable route to the origin and every peer whose last advertised route to it is feasible here has answered the current request, it lets go of a claim none of those peers certifies or carries: it goes offline, which is not an ANNOUNCE_END. #### Compression {#announce-compression} -An ANNOUNCE_START, ANNOUNCE_UPDATE, ANNOUNCE_RESTART, or ANNOUNCE_TAKEOVER MAY copy the head of a path or the tail of a Hop ID list from a live advertisement on the same stream, its base. -A `Base` field names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Base`, so 1 names the latest ANNOUNCE_START or ANNOUNCE_TAKEOVER, and 0 names no base. -The receiver resolves every base against the advertisements live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing, including a predecessor that the ANNOUNCE_TAKEOVER naming it as a base retires. -A publisher MAY always send a `Base` of 0. +An ANNOUNCE_START or ANNOUNCE_TAKEOVER MAY copy the head of its path from a live claim on the same stream, its base. +`Path Base` names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Path Base`, so 1 names the latest ANNOUNCE_START or ANNOUNCE_TAKEOVER, and 0 names no base. +The receiver resolves the base against the claims live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing, including a predecessor that the ANNOUNCE_TAKEOVER naming it as a base retires. +The base need not share the new claim's route. +A publisher MAY always send a `Path Base` of 0. -The subscriber MUST close the session with a PROTOCOL_VIOLATION if a non-zero `Base` names an Announce ID that was never assigned or already retired, a `Keep` is non-zero with a `Base` of 0 or exceeds what the base has, or the resolved path or Hop ID list breaks its own rules. +The subscriber MUST close the session with a PROTOCOL_VIOLATION if a non-zero `Path Base` names an Announce ID that was never assigned or already retired, a `Path Keep` is non-zero with a `Path Base` of 0 or exceeds the base's segments, or the resolved path breaks its own rules. #### Hidden Paths {#hidden} -A route is hidden from a request when a segment of its path below the requested prefix starts with `.` (0x2E). -A segment inside the prefix never hides anything, so a request that names the hidden segment itself (`.stats`) lists what is under it, and a route at or above the prefix has no segment below it. +A claim is hidden from a request when a segment of its path below the requested prefix starts with `.` (0x2E). +A segment inside the prefix never hides anything, so a request that names the hidden segment itself (`.stats`) lists what is under it, and a claim at or above the prefix has no segment below it. Only the first byte counts: `catalog.v2` is not hidden. -A publisher SHOULD NOT announce a hidden route unless the ANNOUNCE_REQUEST set `Hidden`. +A publisher SHOULD NOT announce a hidden claim unless the ANNOUNCE_REQUEST set `Hidden`. Hiding is a convenience for discovery, not access control: a publisher MAY treat a subscriber it trusts, such as another relay in its own cluster, as opted in, and MUST authorize hidden paths like any other. SUBSCRIBE, FETCH, and TRACK resolve a hidden path exactly as any other. #### Routing {#routing} -Each advertisement carries the path of Hop IDs it traversed and an accumulated Route Cost (see [ANNOUNCE_START](#announce-start)), which relays use to build a loop-free mesh. - -A receiver MUST discard an announcement whose reconstructed path contains its own Hop ID: it has looped back, so forwarding it would extend the loop and subscribing through it would route the receiver back to itself. -This is the only loop defense moq-lite requires, and it catches loops of any length. -A conforming sender never sends one (see below), so a receiver MAY instead close the session with a protocol violation; discarding is what keeps a mesh working when one member does not conform. -A Hop ID of 0 means unknown and never matches anything; withholding an ID trades loop detection for privacy. -A Hop ID spans 64 bits. -A receiver MAY assign an identity of its own to a peer that declared 0, as local selection state for filtering that session; it MUST NOT forward that identity as a Hop ID. - -A publisher MUST NOT advertise a path whose entries contain the Hop ID the subscriber declared in its SETUP (see [Hop Parameter](#hop-parameter)). -The receiver can only discard it, and acting on it would form a loop, so sending one is never useful. -Of the paths that remain a publisher SHOULD advertise the best, and nothing when every known path contains that Hop ID. -Selection is per subscriber, so a subscriber that the serving path flows through still receives the best standby path, which is what lets it fail over if its own copy dies. -The per-subscriber winner changing travels as an ANNOUNCE_UPDATE when the new winner serves the same Instance at the same Epoch, and as an ANNOUNCE_RESTART when it serves another Instance or a higher Epoch of the same one; the last qualifying path appearing or disappearing travels as an ANNOUNCE_START or ANNOUNCE_END. -A route continuing what the subscriber was sent for its predecessor travels as an ANNOUNCE_TAKEOVER (see [ANNOUNCE_TAKEOVER](#announce-takeover)); where continuity does not hold, it travels as an ANNOUNCE_START, or an ANNOUNCE_RESTART at the predecessor's own prefix. - -When serving a subscription, a publisher MUST select the source by that same exclusion; if only excluded sources remain, the subscription is unroutable. -Applying one rule to both advertisement and dispatch keeps advertised paths truthful, which is what prevents subscription cycles of any length. - -When resolving a path covered by several routes (across any number of streams), the subscriber SHOULD prefer the most specific covering route (see [Resolution](#resolution)). -Among those, a route is ineligible while another route of the same prefix carries its Instance at a higher Epoch, however cheap it is. -Among the eligible routes, the subscriber SHOULD prefer a path that contains no 0 Hop ID over one that does, then the lowest Route Cost after adding each arriving link's cost (see [Cost Parameter](#cost-parameter)), then a broadcast the receiver publishes itself, breaking ties toward the shortest path, then toward the lowest Instance Hash, then, among routes of one Instance, toward the lowest Chain Hash, and then toward the most recently received, so a reconnecting publisher is not outranked by the stale session it replaced. - -Both hashes are the 64-bit FNV-1a hash, with offset basis `0x420C0DECB00B` and the standard FNV-64 prime, of the requested path's UTF-8 bytes followed by more bytes: for the Instance Hash, the route's Instance; for the Chain Hash, each Hop ID of the route's path, oldest first, as 8 little-endian bytes. -They are keyed on the requested path rather than the route's prefix, so equal-cost advertisers of one prefix, each under its own Instance, share its paths instead of the first one taking them all. -The Instance reads the same on every relay, so one path resolves to the same stream on every relay that holds the same routes. -When choosing which route to advertise for a prefix, the requested path is the prefix itself. -A relay skips both hashes for a route a publisher announced to it directly at the requested path, which no other relay ranks, so a restarted publisher under a new Instance wins at once over its lingering session. - -Routes with the same Instance serve the same Broadcast, so a relay MAY move a live subscription between them, including onto a higher Epoch, continuing from the first frame the subscriber lacks, and a route change (a reconnect, a cheaper path, a draining session) is invisible to the subscriber. -A relay MUST NOT move a subscription to a route with a different Instance: it stays on its Instance's routes and ends with the last of them. -When another Broadcast wins the path, subscriptions already open stay on theirs until the subscriber drops them or the route goes; new requests resolve the winner, and the ANNOUNCE_RESTART tells subscribers to request again. -A receiver of an ANNOUNCE_RESTART (or of the ANNOUNCE_END and ANNOUNCE_START standing in for one) MUST NOT serve a new request under its prefix from anything resolved through the replaced advertisement. -An ANNOUNCE_TAKEOVER is the one exception to staying on an Instance: what was resolved through its predecessor beneath its prefix is the Broadcast the new route serves, so those subscriptions continue on the new route and new requests MAY join them, whatever either route's Instance. -A version before this one carries no Instance, so a receiver of such a session mints one for each ANNOUNCE_START it receives, with Epoch 0, and forwards it as the route's Instance. +Routing follows the Babel routing protocol [RFC8966], keyed by origin node: each node selects one route per origin node and advertises it to its neighbours. + +An origin node names itself with a Node ID: a non-zero 64-bit value, fresh for each process restart and shared by every session of that process. +An origin MUST NOT declare a Node ID with the most significant bit set. +That bit marks a qualified Node ID: a relay files every Node ID a peer with a scoped publish grant sends (see [AUTH_OK](#auth-ok)), the peer's own included, under a qualified Node ID of its own derivation, collision-resistant at 64 bits, and translates it back when it forwards a request on that session. +The derivation is keyed by the key the peer proved with the [Route Key Parameter](#route-key-parameter), so peers sharing one key are filed alike and fail over as one, or by a key unique to the session when the peer proved none. +A relay MUST ignore a qualified Node ID received from such a peer, and claims under its qualified Node IDs outside that peer's grant. +A Node ID is otherwise declared, not proven. + +An origin advertises its own route with a Metric of 0 and a Seqno that only it advances. +A receiver adds the session's link cost (see [Cost Parameter](#cost-parameter)) to the Metric of every route the peer sends; the sum is its metric through that peer. +Each link adds at least 1, so a route's metric strictly increases away from its origin. +A metric that overflows 64 bits makes the route unusable. + +A node keeps a feasibility distance per Node ID: the highest Seqno of any route it advertised, and the lowest metric it advertised at that Seqno, as in [RFC8966] Section 3.5.1. +A route is feasible when no feasibility distance is kept for its Node ID yet, when its Seqno is greater than the distance's, or when its Seqno is equal and its advertised Metric is lower. +A node selects, per Node ID, among its feasible routes that are not held: a session that has not received a GOAWAY, then the highest local priority, then the lowest metric. +It MUST NOT select an infeasible route, and a distance only ever falls within a Seqno, so advertising a longer route never raises it. +A node MAY mark a session upstream. +A route is down-only when it arrived on an upstream session or with its Down Only bit set; a node sends a down-only route with the bit set, and never on an upstream session. + +A node sends its selected route to every origin node it knows on each Announce Stream it answers, and MAY omit routes with no claim matching the stream's request. +A node that does not forward, such as a client, sends only its own route, and holds no feasibility distance for any other. +An origin advances its Seqno whenever its own set of sessions changes. +It never advertises a Seqno or Revision of 2^64-1: a change that would reach it instead rotates its Node ID, ending the old route everywhere and announcing its claims again under the new one. + +A node with no usable route to an origin node holds it while it can still recover: while every route it has to the node is infeasible, or a peer holds a route whose last advertisement is feasible here. +A held route travels as a ROUTE_HOLD, and its claims stay shown but unavailable until a ROUTE_UPDATE makes it usable again. +Otherwise the node lets go, sending ROUTE_END wherever it advertised the route, and its claims go offline. +Neither proves that the origin withdrew its claims. + +A node whose routes to an origin node are all infeasible sends a ROUTE_REQUEST with its distance's Seqno plus 1 to the peer whose route it would select were it feasible. +A node holding a selected route at least that new answers by advertising it, and the origin answers by raising its Seqno to at least the requested one; a request for 2^64-1 rotates the origin's Node ID. +Any other node forwards the request to the peer whose route it would select, even the requester. +Every node keeps one pending request per origin node, its own or forwarded, at the highest Seqno requested, dropping requests no newer, until it selects a route at least that new or has no route left, and sends it again whenever its Seqno rises or that peer changes. + +The receiver of a ROUTE_END echoes it back on the same stream once it dropped the route. +A node drops a feasibility distance once it neither selects nor advertises the route and every ROUTE_END it sent for it was echoed; a closed session counts as an echo. + +A SUBSCRIBE, FETCH, TRACK, TRACKS, or DATAGRAMS request names the origin node that serves it. +A subscriber that names 0 lets the publisher select one (see [Resolution](#resolution)), and a relay forwarding a request names the origin node it selected or was given. +A relay forwards a request naming an origin node only on its selected route to that node, and resets it with UNROUTABLE when it has no usable route to it, so the request never reaches another origin. + +Claims with the same Instance serve the same Broadcast, so a relay MAY move a live subscription between them, including onto a higher Epoch, continuing from the first frame the subscriber lacks, and a route change (a reconnect, a cheaper path, a draining session) is invisible to the subscriber. +A relay MUST NOT move a subscription to a claim with a different Instance: it stays on its Instance's claims and ends with the last of them. +When another Broadcast wins the path, subscriptions already open stay on theirs until the subscriber drops them or the claim goes; new requests resolve the winner, and the ANNOUNCE_RESTART tells subscribers to request again. +A receiver of an ANNOUNCE_RESTART (or of the ANNOUNCE_END and ANNOUNCE_START standing in for one) MUST NOT serve a new request under its prefix from anything resolved through the replaced claim. +An ANNOUNCE_TAKEOVER is the one exception to staying on an Instance: what was resolved through its predecessor beneath its prefix is the Broadcast the new claim serves, so those subscriptions continue on the new claim and new requests MAY join them, whatever either claim's Instance. +A version before this one carries no Instance, so a receiver of such a session mints one for each ANNOUNCE_START it receives, with Epoch 0, and forwards it as the claim's Instance. A minted Instance SHOULD start with `~` (0x7E), so a reader can tell it names an announcement rather than the publisher's stream; a publisher SHOULD NOT name its own Instance with that prefix. A minted Instance is new for every announcement, so a publisher reconnecting or re-announcing through such a hop is another stream. #### Resolution {#resolution} -A SUBSCRIBE, FETCH, or TRACK request names a path, and the receiver resolves it against the routes covering that path, after the per-subscriber exclusion above. +A SUBSCRIBE, FETCH, or TRACK request naming no origin node names a path, and the receiver resolves it against the claims covering that path whose route it selected and is usable. -Only the most specific covering routes are consulted: those with the longest prefix. -A route strictly inside another's paths always ranks above it, and a broadcast's exact path is the most specific route there is, so a concrete announcement shadows every broader route covering it at any cost; routes over the same prefix form one tier. -The winning tier is the whole answer: a refusal from it never falls through to a less specific route, so a service refusing a path does not leak the request to a catch-all, and one unserved path costs one round trip rather than a walk down the candidates. -Nor is a refusal retried at another route of the same prefix. +Only the most specific covering claims are consulted: those with the longest prefix. +A claim strictly inside another's paths always ranks above it, and a broadcast's exact path is the most specific claim there is, so a concrete announcement shadows every broader claim covering it at any cost; claims over the same prefix form one tier. +The winning tier is the whole answer: a refusal from it never falls through to a less specific claim, so a service refusing a path does not leak the request to a catch-all, and one unserved path costs one round trip rather than a walk down the candidates. +Nor is a refusal retried at another claim of the same prefix. -Within the tier, cost and the tie-breaks of [Routing](#routing) order the routes. +Within the tier, a claim is ineligible while another claim of the same prefix carries its Instance at a higher Epoch, however cheap it is. +Among the eligible claims, the receiver SHOULD prefer a route whose session has not received a GOAWAY, then the highest local priority of the route's session, then the lowest cost, the claim's Announce Cost plus its route's metric, then the lowest Rendezvous Hash, and then the most recently received, so a reconnecting publisher is not outranked by the stale session it replaced. +The Rendezvous Hash is the 64-bit FNV-1a hash, with offset basis `0x420C0DECB00B` and the standard FNV-64 prime, of the requested path's UTF-8 bytes followed by the origin's Node ID as 8 little-endian bytes. +Every node that knows the same claims ranks them the same, so equal-cost origins of one prefix share its paths and every relay sends one path to the same origin. +When choosing which claim to advertise for a prefix, the requested path is the prefix itself. -A route resolved this way serves the request like any other, and the route is not consumed by it. -A relay MUST NOT announce a path merely because it resolved it: the covering route stays the only advertisement until the advertiser announces the concrete path, which it SHOULD do once it is producing, so a later request finds the running broadcast by its exact path instead of resolving a second producer. +A claim resolved this way serves the request like any other, and the claim is not consumed by it. +A relay MUST NOT announce a path merely because it resolved it: the covering claim stays the only advertisement until the advertiser announces the concrete path, which it SHOULD do once it is producing, so a later request finds the running broadcast by its exact path instead of resolving a second producer. An advertiser that will not serve a resolved request resets the request stream with a typed code (see [Error Codes](#error-codes)). Every refusal is terminal and propagates without re-resolution, so probing unserved paths costs one round trip per path. -An advertiser signals capacity through its route alone, withdrawing or re-pricing it before it runs out. +An advertiser signals capacity through its claim alone, withdrawing or re-pricing it before it runs out. ### Subscribe A subscriber opens Subscribe Streams to request a Track. @@ -511,7 +559,7 @@ A Track that finished before producing any Group is the one case with nothing to A requested start past the publisher's largest position is the exception: the publisher MUST send SUBSCRIBE_OK without waiting, so a subscriber resuming just past what it holds learns where the live feed is from the answer alone (a quiet track may not reach the start for a long time). A relay knows that position only from its upstream's answer, never from its cache: a group it cached earlier says nothing about where the live feed is now. A rejection is a stream reset: a publisher that cannot serve the subscription (no such track, an ended broadcast, or any other refusal) MUST promptly reset the stream rather than leave it pending, so a subscriber distinguishes "pending" from "refused" by the reset, not by a timeout. -A route claims capability rather than inventory, so a subscription for a covered path that names nothing is refused this way too. +A claim asserts capability rather than inventory, so a subscription for a covered path that names nothing is refused this way too. The track's immutable publisher properties are not carried here; they are fetched once via a [Track Stream](#track-stream). The subscriber needs the track's TRACK_INFO (notably its timescale) to interpret FRAME messages, and MAY open the Track and Subscribe streams concurrently, buffering frames until it arrives. @@ -569,7 +617,7 @@ The publisher replies with listing events, then FINs the stream once the Broadca A Broadcast lists the Tracks its publisher produces, except datagram Tracks, which are never listed: TRACK_INFO does not say which kind a Track is, so a subscriber could not tell whether to SUBSCRIBE or send DATAGRAMS. A Track the publisher serves only on request, such as any name a transcoder answers for, is never listed, and listing never creates one. A removal, either TRACKS_REMOVE or TRACKS_FINISH, promises only that the Track is no longer listed; a SUBSCRIBE for its name MAY still succeed. -TRACKS_FINISH says the Track's publisher declared its end, so its Groups are all the Track will hold; TRACKS_REMOVE says it left unfinished, such as a publisher that went away or a route that was withdrawn, so it may go on elsewhere. +TRACKS_FINISH says the Track's publisher declared its end, so its Groups are all the Track will hold; TRACKS_REMOVE says it left unfinished, such as a publisher that went away or a claim that was withdrawn, so it may go on elsewhere. A relay forwards each removal as its upstream reported it, never as its own cache would suggest. A Track listed again under a removed name is the same Track: within one Broadcast a name always means the same content. @@ -583,7 +631,7 @@ A removal of a Track already sent MAY precede SYNCED. The subscriber MUST close the session with a PROTOCOL_VIOLATION on a TRACKS_ADD for a name already listed on the stream, a TRACKS_REMOVE or TRACKS_FINISH for a name not listed, a second SYNCED, or an unknown event type. A FIN before SYNCED means the Broadcast ended. -A publisher MUST reset a Tracks Stream with NOT_FOUND when the route it would serve the path from covers it by a shorter prefix: a route claims capability, not inventory, so it names no Broadcast to list. +A publisher MUST reset a Tracks Stream with NOT_FOUND when the claim it would serve the path from covers it by a shorter prefix: a claim asserts capability, not inventory, so it names no Broadcast to list. A subscriber MAY skip the Track Stream for a Track it learned from TRACKS_ADD, since the properties are the same immutable ones. ### Datagrams {#datagrams-stream} @@ -916,12 +964,12 @@ The following Setup Parameters are defined: |------|----------------|-------------| | 0x4 | Cost | Cost (i) | |------|----------------|-------------| -| 0x5 | Hop | Hop ID (i) | -|------|----------------|-------------| | 0x6 | Implementation | Tokens (s) | |------|----------------|-------------| | 0x7 | Token | Token (b) | |------|----------------|-------------| +| 0x8 | Route Key | Proof (..) | +|------|----------------|-------------| ### Probe Parameter {#probe-parameter} The Probe Parameter advertises the sender's capability level when acting as a publisher on a [Probe Stream](#probe). @@ -962,23 +1010,15 @@ The role is a hint that only ever narrows the session: a server MUST still enfor Only the client sends it; a client that receives one MUST close the session with a PROTOCOL_VIOLATION. A relay MUST NOT forward it. ### Cost Parameter {#cost-parameter} -The Cost Parameter declares what subscribing from this endpoint costs: a receiver adds the value the sender declared to the Route Cost of every announcement that sender forwards (see [Routing](#routing)). +The Cost Parameter declares what reaching this endpoint costs: a receiver adds the value the sender declared to the Metric of every route that sender sends (see [Routing](#routing)). -The Parameter Value is a variable-length integer in deployment-chosen units, the same units as the Route Cost. -An absent parameter means the default cost of 1, under which the accumulated Route Cost equals the hop count and routing degenerates to shortest-path. -A value of 0 is meaningful and distinct from absent: it makes that direction free, e.g. between two relays in the same datacenter. +The Parameter Value is a variable-length integer in deployment-chosen units, the same units as the Metric and the Announce Cost. +An absent parameter means the default cost of 1, under which a route's metric is its hop count. +A value of 0 is a PROTOCOL_VIOLATION: a free link would leave a route as close as the one it came from, which the feasibility condition relies on never happening. Both endpoints send it and the two values need not match: the parameter prices the sender's own egress. A relay MUST NOT forward it. -A declared cost is an assertion, not an instruction: a receiver MAY charge a locally configured value instead, so a peer cannot reprice its neighbours by declaring itself cheap. - -### Hop Parameter {#hop-parameter} -The Hop Parameter declares the sender's Hop ID: the identity that names it in the paths of announcements it forwards. -The Parameter Value is a variable-length integer; a value of 0 carries no identity and is equivalent to omitting the parameter. - -Declaring it at setup gives the receiver the peer's identity before any other stream arrives, so route selection applies the same exclusion to the peer's subscriptions as to its announcements (see [Routing](#routing)), even on a session that never opens an Announce Stream. -Either endpoint MAY send it; a subscriber-only endpoint with no identity MAY omit it, but a publisher SHOULD have a Hop ID regardless (see [ANNOUNCE_OK](#announce-ok)). -A relay MUST NOT forward it. +A declared cost is an assertion, not an instruction: a receiver MAY charge a locally configured value of at least 1 instead, so a peer cannot reprice its neighbours by declaring itself cheap. ### Implementation Parameter {#implementation-parameter} The Implementation Parameter names the sender's software, so a peer can attribute a session to its build. @@ -997,9 +1037,33 @@ The URI is fixed before the version is negotiated, so a client that may negotiat Only the client sends it; a client that receives one MUST close the session with a PROTOCOL_VIOLATION. A relay MUST NOT forward it. +### Route Key Parameter {#route-key-parameter} +The Route Key Parameter proves that the sender holds an Ed25519 [RFC8032] key, bound to this session, so a receiver recognizes the sender, and endpoints sharing its key, across sessions. + +~~~ +Route Key Proof { + Public Key (256), + Signature (512), +} +~~~ + +**Public Key**: +The sender's Ed25519 public key. + +**Signature**: +The key's Ed25519 signature over the ASCII bytes `moq-lite route key`, then one byte naming the sender's side of the session (0 for the client, 1 for the server), then 32 bytes of keying material exported from the session with the label `EXPORTER-moq-lite-route-key` and an empty context. + +Binding 1 exports with the TLS exporter (Section 7.5 of [RFC8446]), and binding 2 with the WebTransport session's exporter [webtrans-h3] (see [Transports](#transports)). +An endpoint MUST NOT send the parameter on a session that provides no exporter. +The exported material and the sender's side keep a proof from being replayed on another session or reflected back to its sender. + +Either endpoint MAY send it. A receiver MUST close the session with a PROTOCOL_VIOLATION if the value is not 96 bytes. +A receiver that relies on the key MUST verify the signature first, closing the session with a PROTOCOL_VIOLATION if it does not verify; without an exporter on the session, it cannot rely on the key. +A relay MUST NOT forward it. + ## ANNOUNCE_REQUEST {#announce-request} -A subscriber sends an ANNOUNCE_REQUEST message to indicate it wants to receive announcements for any broadcasts with a path that starts with the requested prefix. +A subscriber sends an ANNOUNCE_REQUEST message to indicate it wants to receive the claims, and the routes they travel under, for any broadcasts with a path that starts with the requested prefix. ~~~ ANNOUNCE_REQUEST Message { @@ -1013,30 +1077,24 @@ ANNOUNCE_REQUEST Message { Indicate interest for any broadcasts with a path that starts with this prefix. **Hidden**: -1 to also receive hidden routes (see [Hidden Paths](#hidden)), 0 otherwise. +1 to also receive hidden claims (see [Hidden Paths](#hidden)), 0 otherwise. Any other value is a PROTOCOL_VIOLATION. -The publisher MUST respond with an ANNOUNCE_OK message followed by ANNOUNCE_START messages for any matching routes, followed by ANNOUNCE_START, ANNOUNCE_END, ANNOUNCE_UPDATE, ANNOUNCE_RESTART, and ANNOUNCE_TAKEOVER messages for any future updates, subject to [Routing](#routing). -Implementations SHOULD consider reasonable limits on the number of matching broadcasts to prevent resource exhaustion. +The publisher MUST respond with an ANNOUNCE_OK message followed by the matching routes and claims and any future changes, subject to [Routing](#routing). +The subscriber then sends only [ROUTE_REQUEST](#route-request) and [ROUTE_END](#route-end) echoes on the stream. +Implementations SHOULD consider reasonable limits on the number of matching claims and routes to prevent resource exhaustion. ## ANNOUNCE_OK {#announce-ok} A publisher sends an ANNOUNCE_OK message exactly once, as the first message on the response side of an Announce Stream. -It carries metadata that is constant for the lifetime of the stream and applies to every announcement that follows. ~~~ ANNOUNCE_OK Message { - Message Length (i) - Hop ID (i) + Message Length (i) = 0 } ~~~ -**Hop ID**: -The publisher's own Hop ID. -This is treated as the implicit trailing entry of every ANNOUNCE_START, ANNOUNCE_UPDATE, ANNOUNCE_RESTART, and ANNOUNCE_TAKEOVER Hop ID list on this stream; those messages MUST NOT repeat this value as the last entry of their `Hop ID` list. -The value 0 is reserved to mean "unknown": either no Hop ID was assigned (e.g. when bridging from an older protocol version) or the endpoint deliberately withholds it to obscure the underlying routing. -A publisher that assigns a Hop ID MUST choose a non-zero value, and SHOULD assign itself one (a fresh random value per session suffices), so downstream receivers can detect loops through it. -Receivers reconstruct the full path as `Hop IDs ++ [ANNOUNCE_OK.Hop ID]`. +ANNOUNCE_OK has no body and reserves no fields for a route-key proof. ## SYNCED {#synced} The initial set on a discovery stream has been delivered. @@ -1048,16 +1106,144 @@ SYNCED Message { } ~~~ -SYNCED has no body and assigns no Announce ID. +SYNCED has no body and assigns no id. A second SYNCED on the same stream is a PROTOCOL_VIOLATION. An Announce Stream ending before SYNCED is a PROTOCOL_VIOLATION; a Tracks Stream FIN before SYNCED means the Broadcast ended (see [Tracks](#tracks-stream)). +## ROUTE_START {#route-start} +A publisher sends a ROUTE_START message when it reaches an origin node (see [Routing](#routing)). +Each ROUTE_START implicitly assigns the next Route ID on the stream, later referenced by ROUTE_UPDATE, ROUTE_HOLD, ROUTE_END, ANNOUNCE_FLOOR, ANNOUNCE_START, and ANNOUNCE_TAKEOVER (see [Announce](#announce)). + +~~~ +ROUTE_START Message { + Type (i) = 0x6 + Message Length (i) + Node ID (i), + Seqno (i), + Metric (i), + Down Only (8), +} +~~~ + +**Type**: +Set to 0x6 to indicate a ROUTE_START message. + +**Node ID**: +The origin node, as the publisher names it on this session. +0 is a PROTOCOL_VIOLATION. + +**Seqno**: +The origin's sequence number of the route, which only the origin advances. +2^64-1 is a PROTOCOL_VIOLATION. + +**Metric**: +The publisher's metric to the origin node, 0 when it is the origin. + +**Down Only**: +1 if the route is down-only (see [Routing](#routing)), 0 otherwise. +Any other value is a PROTOCOL_VIOLATION. + + +## ROUTE_UPDATE {#route-update} +A publisher sends a ROUTE_UPDATE message when a route it sent changes, referencing its Route ID. + +~~~ +ROUTE_UPDATE Message { + Type (i) = 0x7 + Message Length (i) + Route ID (i), + Seqno (i), + Metric (i), + Down Only (8), +} +~~~ + +**Type**: +Set to 0x7 to indicate a ROUTE_UPDATE message. + +**Route ID**: +The ordinal implicitly assigned by a prior ROUTE_START on this stream. + +**Seqno**, **Metric**, and **Down Only**: +As defined for [ROUTE_START](#route-start), replacing the route's. +A ROUTE_UPDATE for a held route makes it usable again. + + +## ROUTE_HOLD {#route-hold} +A publisher sends a ROUTE_HOLD message when it has no usable route to the origin node but still holds the route and its claims while it recovers (see [Routing](#routing)), referencing its Route ID. +The receiver keeps the route's last Seqno, Metric, and Down Only, and does not select it until a ROUTE_UPDATE. + +~~~ +ROUTE_HOLD Message { + Type (i) = 0xB + Message Length (i) + Route ID (i) +} +~~~ + +**Type**: +Set to 0xB to indicate a ROUTE_HOLD message. + +**Route ID**: +The ordinal implicitly assigned by a prior ROUTE_START on this stream. + + +## ROUTE_END {#route-end} +A publisher sends a ROUTE_END message when it lets go of a route, referencing its Route ID. +It retires the Route ID and every claim under the route, whose ANNOUNCE_END the publisher does not send. +It claims nothing about the origin's claims, which may still be served elsewhere. + +The subscriber sends the same message back on the stream once it dropped the route, its echo (see [Routing](#routing)). +A subscriber MUST send exactly one echo for each ROUTE_END, and a publisher MUST close the session with a PROTOCOL_VIOLATION on an echo naming a Route ID it has not ended or already saw echoed. + +~~~ +ROUTE_END Message { + Type (i) = 0x8 + Message Length (i) + Route ID (i) +} +~~~ + +**Type**: +Set to 0x8 to indicate a ROUTE_END message. + +**Route ID**: +The ordinal implicitly assigned by a prior ROUTE_START on this stream. + + +## ROUTE_REQUEST {#route-request} +A subscriber sends a ROUTE_REQUEST message to ask for a route to an origin node at least as new as a Seqno (see [Routing](#routing)). +It names the origin node rather than a Route ID, so it stays valid while a ROUTE_END for that route is in flight. + +~~~ +ROUTE_REQUEST Message { + Type (i) = 0xA + Message Length (i) + Node ID (i), + Seqno (i), +} +~~~ + +**Type**: +Set to 0xA to indicate a ROUTE_REQUEST message. + +**Node ID**: +The origin node, as the publisher names it on this session. +0 is a PROTOCOL_VIOLATION. + +**Seqno**: +The Seqno the subscriber needs. +A publisher with no route to the origin node drops the request. + +A publisher MUST close the session with a PROTOCOL_VIOLATION if it receives any message type other than ROUTE_REQUEST or ROUTE_END after the ANNOUNCE_REQUEST. + + ## ANNOUNCE_START {#announce-start} -A publisher sends an ANNOUNCE_START message to advertise a route: a claim that paths under a prefix can be served. +A publisher sends an ANNOUNCE_START message to advertise a claim of a route's origin node: that paths under a prefix can be served. Each ANNOUNCE_START implicitly assigns the next Announce ID on the stream, later referenced by ANNOUNCE_END, ANNOUNCE_UPDATE, ANNOUNCE_RESTART, and ANNOUNCE_TAKEOVER (see [Announce](#announce)). -Only the suffix is encoded on the wire, as the full route prefix can be constructed by prepending the requested prefix. +Only the suffix is encoded on the wire, as the full claim prefix can be constructed by prepending the requested prefix. ~~~ ANNOUNCE_START Message { @@ -1065,18 +1251,11 @@ ANNOUNCE_START Message { Message Length (i) Path Base (i), Path Keep (i), - Route Prefix Suffix (s), + Claim Prefix Suffix (s), + Route ID (i), Instance (b), Epoch (i), - Hops (..), - Route Cost (i), -} - -Hops { - Hop Base (i), - Hop Count (i), - Hop ID (i) ..., - Hop Keep (i), + Announce Cost (i), } ~~~ @@ -1084,53 +1263,34 @@ Hops { Set to 0x0 to indicate an ANNOUNCE_START message. **Path Base** and **Path Keep**: -The suffix begins with the first `Path Keep` segments of the suffix advertised by the base that `Path Base` names (see [Compression](#announce-compression)). +The suffix begins with the first `Path Keep` segments of the suffix claimed by the base that `Path Base` names (see [Compression](#announce-compression)). + +**Claim Prefix Suffix**: +The remaining segments of the suffix, which is combined with the requested prefix to form the claim's full prefix. +An empty suffix claims the requested prefix itself, which is how a claim covering more than the request presents (see [Announce](#announce)). -**Route Prefix Suffix**: -The remaining segments of the suffix, which is combined with the requested prefix to form the route's full prefix. -An empty suffix advertises the requested prefix itself, which is how a route covering more than the request presents (see [Announce](#announce)). +**Route ID**: +The route the claim travels under: its origin node is the claim's. +A claim never changes its Route ID. **Instance**: -The stream the route serves (see [Broadcast](#broadcast)): non-empty UTF-8 text. +The stream the claim serves (see [Broadcast](#broadcast)): non-empty UTF-8 text. An empty or non-UTF-8 Instance is a PROTOCOL_VIOLATION. A relay forwards the Instance unchanged. **Epoch**: -Which incarnation of the Instance the route serves: a counter the publisher raises within one Instance, 0 unless it keeps an Instance across restarts. +Which incarnation of the Instance the claim serves: a counter the publisher raises within one Instance, 0 unless it keeps an Instance across restarts. A relay forwards the Epoch unchanged. Only an ANNOUNCE_RESTART or ANNOUNCE_TAKEOVER changes the Instance or the Epoch; ANNOUNCE_UPDATE never does. -**Hop Base** and **Hop Keep**: -The Hop ID list ends with the last `Hop Keep` entries of the list advertised by the base that `Hop Base` names (see [Compression](#announce-compression)), following the literal `Hop ID` entries. -The two bases are independent. - -**Hop Count**: -The number of literal Hop ID entries that follow. -The resolved list does NOT include the publisher's own `Hop ID` from ANNOUNCE_OK. -An empty list indicates either that the announcement originated locally on the publisher (the publisher itself is the origin) or that the upstream peer does not support hop tracking. -A receiver MUST close the session with a PROTOCOL_VIOLATION if the Hop Count does not match the number of subsequent Hop ID entries. - -**Hop ID**: -A unique identifier for each relay in the path from the origin publisher, ordered from origin to the upstream of the responding publisher. -The responding publisher's own Hop ID is NOT included in the resolved list; it is carried once in ANNOUNCE_OK, so the total path length is the resolved list's length plus 1 (`Hop Count + Hop Keep + 1`). -When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE_OK `Hop ID` to the resolved list, since that ID is no longer implicit downstream. -A Hop ID value of 0 means the hop is unknown: either it was never assigned or a relay deliberately withholds it (see [Routing](#routing)). -A received 0 is forwarded unchanged. -When bridging an announcement from an upstream that sent no hop list, a relay writes 0 for that hop. -An identity a receiver assigned that upstream is local selection state and MUST NOT be forwarded as a Hop ID. - -A receiver MUST close the session with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in the resolved list. -Duplicate values of 0 are not a violation, since 0 identifies nothing and any number of hops may be unknown. - -**Route Cost**: -The static production and link price, in units chosen by the deployment. -The publisher seeds its production cost (0 for content it already produces, larger for a standby that starts work on demand). Each forwarding relay adds the upstream peer's declared link cost (see [Cost Parameter](#cost-parameter)). -Each sum MUST saturate at 2^62-1. +**Announce Cost**: +The origin's price for this prefix, in the units of the [Cost Parameter](#cost-parameter): 0 for content it already produces, larger for a standby that starts work on demand. +A relay forwards it unchanged; the cost of a path through the claim is the Announce Cost plus the route's metric. ## ANNOUNCE_END {#announce-end} -A publisher sends an ANNOUNCE_END message to retract a previously started route, referencing its Announce ID. +A publisher sends an ANNOUNCE_END message when the origin withdrew a claim, referencing its Announce ID. The id is retired and MUST NOT be referenced again. Retraction claims nothing about the content: a broadcast whose publisher stops advertising may remain readable by exact path, serving its stored groups over FETCH. Retraction does not disturb subscriptions already in flight, which conclude normally with SUBSCRIBE_FINISH. @@ -1148,23 +1308,21 @@ ANNOUNCE_END Message { Set to 0x1 to indicate an ANNOUNCE_END message. **Announce ID**: -The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. -Referencing an id that was never assigned, or one already retired, is a protocol violation. -Announce IDs are never reused within a stream; a route that is announced again after an ANNOUNCE_END gets a fresh id from its next ANNOUNCE_START. +The ordinal implicitly assigned by a prior ANNOUNCE_START or ANNOUNCE_TAKEOVER on this stream. +A claim that is announced again after an ANNOUNCE_END gets a fresh id from its next ANNOUNCE_START. ## ANNOUNCE_UPDATE {#announce-update} -A publisher sends an ANNOUNCE_UPDATE message to atomically update a previously started advertisement's metadata, referencing its Announce ID. -The route's prefix is unchanged and the id stays live; the Hop ID list MAY differ from the original (e.g. after a relay failover or upstream restart). -An update carries no content claim: in-flight subscriptions under the route are undisturbed. +A publisher sends an ANNOUNCE_UPDATE message when the origin re-prices a claim, referencing its Announce ID. +The claim's prefix and route are unchanged and the id stays live. +An update carries no content claim: in-flight subscriptions under the claim are undisturbed. ~~~ ANNOUNCE_UPDATE Message { Type (i) = 0x2 Message Length (i) Announce ID (i), - Hops (..), - Route Cost (i), + Announce Cost (i), } ~~~ @@ -1172,19 +1330,16 @@ ANNOUNCE_UPDATE Message { Set to 0x2 to indicate an ANNOUNCE_UPDATE message. **Announce ID**: -The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. -Referencing an id that was never assigned, or one already retired by an ANNOUNCE_END, is a protocol violation. +As defined for [ANNOUNCE_END](#announce-end). -**Hops** and **Route Cost**: -As defined for [ANNOUNCE_START](#announce-start). -`Hop Base` may name this advertisement itself, which resolves against the list being replaced. -An update whose only change is a Route Cost is valid: it is how a relay re-prices a route without disturbing it. +**Announce Cost**: +As defined for [ANNOUNCE_START](#announce-start), replacing the claim's. ## ANNOUNCE_RESTART {#announce-restart} -A publisher sends an ANNOUNCE_RESTART message when a previously started advertisement now serves another Instance, or a higher Epoch of the same one (see [Routing](#routing)), referencing its Announce ID. -The route's prefix is unchanged and the id stays live. -Under another Instance, whatever the subscriber resolved under the prefix through the advertisement is the old stream: in-flight subscriptions on it are undisturbed, and a new request resolves afresh. +A publisher sends an ANNOUNCE_RESTART message when a claim now serves another Instance, or a higher Epoch of the same one (see [Routing](#routing)), referencing its Announce ID. +The claim's prefix and route are unchanged and the id stays live. +Under another Instance, whatever the subscriber resolved under the prefix through the claim is the old stream: in-flight subscriptions on it are undisturbed, and a new request resolves afresh. Under a higher Epoch of the same Instance the stream continues, and so do its subscriptions. ~~~ @@ -1194,8 +1349,7 @@ ANNOUNCE_RESTART Message { Announce ID (i), Instance (b), Epoch (i), - Hops (..), - Route Cost (i), + Announce Cost (i), } ~~~ @@ -1203,15 +1357,14 @@ ANNOUNCE_RESTART Message { Set to 0x3 to indicate an ANNOUNCE_RESTART message. **Announce ID**: -As defined for [ANNOUNCE_UPDATE](#announce-update). +As defined for [ANNOUNCE_END](#announce-end). -**Instance**, **Epoch**, **Hops**, and **Route Cost**: -As defined for [ANNOUNCE_START](#announce-start), replacing the advertisement's. -`Hop Base` may name this advertisement itself, which resolves against the list being replaced. +**Instance**, **Epoch**, and **Announce Cost**: +As defined for [ANNOUNCE_START](#announce-start), replacing the claim's. ## ANNOUNCE_TAKEOVER {#announce-takeover} -A publisher sends an ANNOUNCE_TAKEOVER message to advertise a route that continues what a live advertisement, its predecessor, served beneath the new route's prefix. -A worker advertising a prefix under its own Instance answers a request under it by publishing that path under a fresh Instance; the ANNOUNCE_TAKEOVER keeps subscriptions already resolved through the prefix rather than resetting them to the more specific route. +A publisher sends an ANNOUNCE_TAKEOVER message to advertise a claim that continues what a live claim, its predecessor, served beneath the new claim's prefix. +A worker advertising a prefix under its own Instance answers a request under it by publishing that path under a fresh Instance; the ANNOUNCE_TAKEOVER keeps subscriptions already resolved through the prefix rather than resetting them to the more specific claim. It is an ANNOUNCE_START in every other respect and assigns the next Announce ID. ~~~ @@ -1221,11 +1374,11 @@ ANNOUNCE_TAKEOVER Message { Predecessor (i), Path Base (i), Path Keep (i), - Route Prefix Suffix (s), + Claim Prefix Suffix (s), + Route ID (i), Instance (b), Epoch (i), - Hops (..), - Route Cost (i), + Announce Cost (i), } ~~~ @@ -1233,19 +1386,40 @@ ANNOUNCE_TAKEOVER Message { Set to 0x5 to indicate an ANNOUNCE_TAKEOVER message. **Predecessor**: -The Announce ID of a live advertisement whose prefix covers the new route's prefix. -One at the predecessor's own prefix retires the predecessor's id, which this advertisement replaces; otherwise the predecessor stays live. -The receiver MUST close the session with a PROTOCOL_VIOLATION if the predecessor was never assigned, is already retired, or does not cover the new prefix. +The Announce ID of a live claim under the same route whose prefix covers the new claim's prefix. +One at the predecessor's own prefix retires the predecessor's id, which this claim replaces; otherwise the predecessor stays live. +The receiver MUST close the session with a PROTOCOL_VIOLATION if the predecessor was never assigned, is already retired, is under another route, or does not cover the new prefix. -**Path Base**, **Path Keep**, **Route Prefix Suffix**, **Instance**, **Epoch**, **Hops**, and **Route Cost**: +**Path Base**, **Path Keep**, **Claim Prefix Suffix**, **Route ID**, **Instance**, **Epoch**, and **Announce Cost**: As defined for [ANNOUNCE_START](#announce-start), resolved before the predecessor retires. -The receiver treats whatever it resolved through the predecessor beneath the new prefix as served by the new route: subscriptions on it continue, and a new request for those paths MAY join them. -Only what the predecessor's current advertisement resolved continues; anything resolved before an earlier ANNOUNCE_RESTART of it does not. +The receiver treats whatever it resolved through the predecessor beneath the new prefix as served by the new claim: subscriptions on it continue, and a new request for those paths MAY join them. +Only what the predecessor's current claim resolved continues; anything resolved before an earlier ANNOUNCE_RESTART of it does not. + +A publisher MUST send an ANNOUNCE_TAKEOVER only when the new claim serves exactly what the subscriber resolved through the predecessor beneath its prefix: the publisher that answered requests through the predecessor now advertises their paths. +A relay forwards one only when the subscriber was last sent that predecessor, and every claim the relay sent at its prefix serves the same Instance; otherwise it sends an ANNOUNCE_START, or an ANNOUNCE_RESTART at the predecessor's own prefix. + + +## ANNOUNCE_FLOOR {#announce-floor} +A publisher sends an ANNOUNCE_FLOOR message to close a batch of a route's claims (see [Floors](#floors)). -A publisher MUST send an ANNOUNCE_TAKEOVER only when the new route serves exactly what the subscriber resolved through the predecessor beneath its prefix: the publisher that answered requests through the predecessor now advertises their paths. -A relay forwards one only when the subscriber was last sent that predecessor, and every route the relay presented there serves the same Instance; otherwise it sends an ANNOUNCE_START, or an ANNOUNCE_RESTART at the predecessor's own prefix. -Before moq-lite-07, a publisher sends an ANNOUNCE_START instead, or an ANNOUNCE_END and then an ANNOUNCE_START at the predecessor's own prefix. +~~~ +ANNOUNCE_FLOOR Message { + Type (i) = 0x9 + Message Length (i) + Route ID (i), + Revision (i), +} +~~~ + +**Type**: +Set to 0x9 to indicate an ANNOUNCE_FLOOR message. + +**Route ID**: +The ordinal implicitly assigned by a prior ROUTE_START on this stream. + +**Revision**: +The origin's Revision at which the floor certifies the stream's claims for the route, plus one, or 0 when it certifies nothing. ## SUBSCRIBE @@ -1257,6 +1431,7 @@ SUBSCRIBE Message { Subscribe ID (i) Broadcast Path (s) Instance (b) + Node ID (i) Track Name (s) Subscriber Priority (8) Group Order (8) @@ -1284,6 +1459,9 @@ Anchor { A unique identifier chosen by the subscriber. A Subscribe ID MUST NOT be reused within the same session, even if the prior subscription has been closed. +**Instance** and **Node ID**: +As in [TRACK](#track). + **Subscriber Priority**: The priority of the subscription within the session, represented as a u8 with 128 as the midpoint. The publisher SHOULD transmit *higher* values first during congestion. @@ -1389,6 +1567,7 @@ TRACK Message { Message Length (i) Broadcast Path (s) Instance (b) + Node ID (i) Track Name (s) } ~~~ @@ -1398,8 +1577,12 @@ The broadcast path of the track. **Instance**: The stream the subscriber expects, encoded as in [ANNOUNCE_START](#announce-start), or empty for whichever wins the path. -A publisher MUST refuse a request with UNROUTABLE when no route of that Instance serves the path, so a request is never answered by a different stream than the one it named. -A relay fills in the Instance of the route it resolved when forwarding a request upstream; TRACKS, SUBSCRIBE and FETCH carry it the same way. +A publisher MUST refuse a request with UNROUTABLE when no claim of that Instance serves the path, so a request is never answered by a different stream than the one it named. +A relay fills in the Instance of the claim it resolved when forwarding a request upstream; TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS carry it the same way. + +**Node ID**: +The origin node that serves the request, as the publisher names it on this session, or 0 for whichever the publisher selects (see [Routing](#routing)). +TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS carry it the same way. **Track Name**: The name of the track. @@ -1466,14 +1649,15 @@ TRACKS Message { Message Length (i) Broadcast Path (s) Instance (b) + Node ID (i) } ~~~ **Broadcast Path**: The path of the Broadcast to list. -**Instance**: -The stream the subscriber expects, as in [TRACK](#track). +**Instance** and **Node ID**: +As in [TRACK](#track). The listing is pinned to the stream that answers it and ends with that stream. ## TRACKS_ADD {#tracks-add} @@ -1532,6 +1716,7 @@ DATAGRAMS Message { Subscribe ID (i) Broadcast Path (s) Instance (b) + Node ID (i) Track Name (s) Subscriber Priority (8) } @@ -1684,6 +1869,7 @@ FETCH Message { Message Length (i) Broadcast Path (s) Instance (b) + Node ID (i) Track Name (s) Subscriber Priority (8) Subscriber Max Age (i) @@ -1696,6 +1882,9 @@ FETCH Message { **Broadcast Path**: The broadcast path of the track to fetch from. +**Instance** and **Node ID**: +As in [TRACK](#track). + **Track Name**: The name of the track to fetch from. @@ -1774,7 +1963,7 @@ An endpoint MUST close the session with a protocol violation if it receives more A peer that reconnects to a provided URI SHOULD keep using that URI for subsequent reconnects rather than reverting to the original. -Routes that arrived on a session that received a GOAWAY stay usable until the session ends. +Routes that arrived on a session that received a GOAWAY stay usable until the session ends, ranked below every other (see [Routing](#routing)). ## AUTH {#auth-message} AUTH is the first message on an Auth Stream, presenting a token. @@ -1931,11 +2120,12 @@ The `Message Length` describes the payload size on the wire. - Switched every variable-length integer, including SETUP parameter values, from QUIC's two-bit length prefix to moq-transport's leading-ones encoding, widening the range to 64 bits. - Capped the Message Length of every message except FRAME at 65,535 bytes. SETUP parameter counts are bounded by Message Length, without a separate count cap. -- Any Setup Stream or bidirectional stream message that fails to decode closes the session with PROTOCOL_VIOLATION; previously only an unexpected Message Length did, as a SHOULD. This includes a frame bound without its group bound, and a Hop Count that disagrees with its Hop ID entries, each of which previously reset only the stream. A stream ending mid-message outside the Setup and Auth Streams, a malformed GROUP or FRAME on a Group or Fetch Stream, and an unknown stream type still affect only that stream. +- Any Setup Stream or bidirectional stream message that fails to decode closes the session with PROTOCOL_VIOLATION; previously only an unexpected Message Length did, as a SHOULD. This includes a frame bound without its group bound, which previously reset only the stream. A stream ending mid-message outside the Setup and Auth Streams, a malformed GROUP or FRAME on a Group or Fetch Stream, and an unknown stream type still affect only that stream. - A session without a negotiated subprotocol negotiates the version in a draft-14 CLIENT_SETUP and SERVER_SETUP exchange. - Added the reliable ordered RTCDataChannel binding, with one Qmux Record per binary message and no datagrams. - Added the SETUP `Implementation` parameter (0x6): the sender's software as `name/version` product tokens. - Added the client-only Token Setup Parameter (0x7), carrying the connection credential in SETUP instead of only the request URI. +- Added the Route Key Setup Parameter (0x8): an Ed25519 public key and its signature over keying material exported from the session. - Added Padding Stream (0x2), read and discarded incrementally by either endpoint. - A Report-level publisher reads and ignores PROBE targets instead of resetting the Probe Stream. - A GOAWAY recipient MAY keep opening requests on the current session until it has moved to a replacement session; previously it MUST NOT open new streams. The sender SHOULD keep answering them and MAY reset one with GOING_AWAY, which the recipient SHOULD retry on another route. @@ -1962,16 +2152,22 @@ The `Message Length` describes the payload size on the wire. - Limited a broadcast path to 32 segments and 4,096 bytes. - A path never has an empty segment, so a leading, trailing, or doubled `/` is a PROTOCOL_VIOLATION rather than a second spelling of the same path. - A `\` in a path escapes only `\` or `/`; any other `\` is a PROTOCOL_VIOLATION rather than a second spelling of the same namespace field. -- Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. -- Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining routes that end before delivery. -- Added `Instance` and `Epoch` to ANNOUNCE_START and ANNOUNCE_RESTART, and `Instance` to TRACK, TRACKS, SUBSCRIBE, and FETCH. The Instance is non-empty UTF-8 text naming one continuous stream, and a path and an Instance name one Broadcast; the Epoch is a counter within one Instance. A relay MAY move a subscription between routes with the same Instance, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between routes with different Instances. Within one Instance only its highest Epoch is eligible. A request's Instance is empty when it pins none. Replaces the first-hop identity. +- Hid claims with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. +- Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining claims that end before delivery. +- Added `Instance` and `Epoch` to ANNOUNCE_START and ANNOUNCE_RESTART, and `Instance` to TRACK, TRACKS, SUBSCRIBE, and FETCH. The Instance is non-empty UTF-8 text naming one continuous stream, and a path and an Instance name one Broadcast; the Epoch is a counter within one Instance. A relay MAY move a subscription between claims with the same Instance, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between claims with different Instances. Within one Instance only its highest Epoch is eligible. A request's Instance is empty when it pins none. Replaces the first-hop identity. - Added ANNOUNCE_RESTART (0x3): a live advertisement now serves another Instance, or a higher Epoch of the same one. -- Added ANNOUNCE_TAKEOVER (0x5): a route that continues what a live advertisement served beneath it, such as a job answering a request through a worker's prefix at its exact path, so subscriptions resolved through the prefix are not reset. +- Added ANNOUNCE_TAKEOVER (0x5): a claim that continues what a live claim served beneath it, such as a job answering a request through a worker's prefix at its exact path, so subscriptions resolved through the prefix are not reset. - A receiver mints an Instance, starting with `~`, for each announcement a session of an earlier version delivers. -- Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. -- Hop IDs span 64 bits. -- Replaced Warm and Cold Route Cost with one static Route Cost. -- Added the Instance Hash and Chain Hash tie-breaks after the shortest path: hashes of the requested path with the route's Instance, then with its Hop IDs, so equal-cost advertisers of one prefix share its paths. Both are skipped for a route a publisher announced directly at the requested path. +- Added announce compression: ANNOUNCE_START and ANNOUNCE_TAKEOVER gain `Path Base` and `Path Keep` to copy the head of a live claim's suffix. +- Split routing into two layers on the Announce Stream. ROUTE_START (0x6), ROUTE_UPDATE (0x7), and ROUTE_END (0x8) advertise how the publisher reaches an origin node, named by a Node ID, with a seqno, an additive metric, and a down-only bit, and loops are prevented by Babel's feasibility condition. Every ANNOUNCE_START and ANNOUNCE_TAKEOVER names its route's Route ID in place of the Hop ID list, so a link change sends one ROUTE_UPDATE per origin rather than an update per claim. Removed the Hop ID list, ANNOUNCE_OK's Hop ID, and the SETUP Hop parameter (0x5). +- Added ROUTE_HOLD (0xB), which holds a route while it recovers; ROUTE_END lets it go. Neither withdraws the origin's claims, and the subscriber echoes every ROUTE_END back. +- Added ROUTE_REQUEST (0xA), sent by the subscriber on the Announce Stream to ask for a route with a newer seqno when every route it has is infeasible. +- Added ANNOUNCE_FLOOR (0x9), closing each batch of a route's claims with the origin Revision at which the stream's claims across the requested prefix are certified plus one, or 0 to certify nothing, so a stale copy never revives a claim whose withdrawal a newer one delivered. A floor certifies only the claims its own request shows. +- A route presents only its most specific claim covering the requested prefix, moved to the next covering claim when it is withdrawn rather than ended. +- Added `Node ID` to TRACK, TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS: the origin node the first hop selected, followed by every later hop. +- Replaced Warm and Cold Route Cost with the origin's Announce Cost, unchanged in transit. ANNOUNCE_UPDATE carries only the Announce Cost. +- The Cost parameter must be at least 1. +- Replaced the shortest-path, Hop ID, Instance Hash, and Chain Hash tie-breaks with the local priority of the route's session, the Announce Cost plus the route's metric, and a Rendezvous Hash of the requested path and the origin's Node ID. - Removed the standby-ordering deployment requirements on Route Cost seeds and bounds. ### Tracks {#changelog-07-tracks} @@ -2189,10 +2385,11 @@ A token presented on an Auth Stream is as sensitive as one in the request URI, a GOAWAY carries an optional New Session URI that asks the peer to reconnect elsewhere. A malicious or compromised peer could use this to redirect a client to an attacker-controlled server. A recipient MUST validate the URI against local policy (scheme, authority, and port) before reconnecting, and MUST NOT reconnect if validation fails (see [GOAWAY](#goaway)). Migrated subscriptions carry no implicit trust from the prior session; the new session is authenticated independently. ## Routing Metadata and Privacy -Hop IDs (see [ANNOUNCE_OK](#announce-ok) and [ANNOUNCE_START](#announce-start)) expose the relay path of a broadcast, which may reveal internal topology. A relay that does not wish to disclose its position MAY use the reserved value 0 ("unknown") instead of a stable identifier, at the cost of losing loop detection through itself (see [Routing](#routing)). The Hop ID announcement filter (see [Hop Parameter](#hop-parameter)) exists for loop avoidance, not access control: a subscriber cannot verify that a publisher honored it, so it MUST NOT be relied upon to hide a broadcast from a peer that declared its Hop ID. +A Node ID (see [Routing](#routing)) is opaque and fresh for each restart: it tells a peer which claims share an origin, and a route's metric how far that origin is, but not the relays in between. +A Node ID is declared, not proven, so a peer can forge another origin's Node ID or advertise a better route than it has. Qualifying the Node IDs of a peer with a scoped publish grant, under the key it proved in SETUP, confines such a forgery to the claims inside that grant; an unscoped peer is trusted with every origin. ## Resource Exhaustion -A peer can open many streams (subscriptions, announcements, fetches), request large announce prefixes, or advertise broad routes. Implementations SHOULD bound the number of concurrent subscriptions, announce matches, and cached groups; an endpoint whose peer goes past its bound on subscriptions or announcements SHOULD close the session with TOO_MANY_REQUESTS, and SHOULD rely on QUIC flow control and stream limits to backpressure a misbehaving peer (see [ANNOUNCE_REQUEST](#announce-request)). Expiration (see [Expiration](#expiration)) bounds how long stale groups consume memory and flow control. A broad route invites a request for any covered path, each of which may start work: an advertiser SHOULD bound the work it starts, withdrawing its route before it runs out, and every refusal is terminal, so a flood of requests costs the mesh one round trip each rather than a search (see [Resolution](#resolution)). +A peer can open many streams (subscriptions, announcements, fetches), request large announce prefixes, or advertise broad claims. Implementations SHOULD bound the number of concurrent subscriptions, announce matches, routes, and cached groups; an endpoint whose peer goes past its bound on subscriptions or announcements SHOULD close the session with TOO_MANY_REQUESTS, and SHOULD rely on QUIC flow control and stream limits to backpressure a misbehaving peer (see [ANNOUNCE_REQUEST](#announce-request)). Expiration (see [Expiration](#expiration)) bounds how long stale groups consume memory and flow control. A broad claim invites a request for any covered path, each of which may start work: an advertiser SHOULD bound the work it starts, withdrawing its claim before it runs out, and every refusal is terminal, so a flood of requests costs the mesh one round trip each rather than a search (see [Resolution](#resolution)). ## Datagram Injection Datagrams are routed to a subscription solely by Subscribe ID and carry no per-group authentication beyond that of the QUIC connection. On an unmodified QUIC/WebTransport connection this is sufficient, since datagrams are protected by the transport. A subscriber MUST silently drop any datagram with an unknown Subscribe ID (see [Datagrams](#datagrams)). diff --git a/flake.lock b/flake.lock index edd04036caf..47cb813c537 100644 --- a/flake.lock +++ b/flake.lock @@ -35,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1792842277, - "narHash": "sha256-pGPnZGi8y9d76xNeracniXO3dsYVROEmMkMc8jD0Ihc=", + "lastModified": 1793166571, + "narHash": "sha256-3+DlA8JY6abKt8G9hoZgTiSNLxdkGTn2a5MeiNDXmIc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d3b13ad75cc492690728f9307e443dec9ef9a20e", + "rev": "fa45202b90a351983649116adb577827b2a9a679", "type": "github" }, "original": { @@ -123,11 +123,11 @@ ] }, "locked": { - "lastModified": 1793086596, - "narHash": "sha256-ObKS44vw4IIVpQ69YJzUGjZ0IwnpWoUlwjJSY7gefdo=", + "lastModified": 1793174968, + "narHash": "sha256-u5nUf4cewaT36iIzG1s/VECktqUMiZaGF5BsgervsvQ=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c7e9d512653a35ef4d5879c3df4d44d7b78d0e6d", + "rev": "19ecdd08213836b36c4296a3a4f4af5a3d5308db", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 2d0545d981e..21edb2d260f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,16 +1,16 @@ { description = "MoQ - Media over QUIC"; - # Pre-built binaries live in our binary cache at nix.moq.dev. Of the - # packages, only tagged releases are pushed (moq-relay-v*, moq-cli-v*, etc.), - # so pin the flake ref to a recent tag to get a hit. The default branch HEAD - # builds from source, though its dev shell is cached: - # nix run github:moq-dev/moq/moq-relay-v0.12.4#moq-relay --accept-flake-config + # nix.moq.dev caches the default branch's dev shell, along with moq-relay + # and moq on x86 Linux, with a 30-day expiry: + # nix run github:moq-dev/moq#moq-relay --accept-flake-config + # Release Nix retains tagged packages permanently once a release cut + # carries that workflow from main; earlier tags still build from source. # # --accept-flake-config opts into the nixConfig below for one command. To # trust the cache permanently instead, add these settings to nix.conf. # - # The dev shell paths expire 30 days after upload, possibly before a path + # The CI paths expire 30 days after upload, possibly before a path # that depends on them; `fallback` builds whatever is missing instead of # failing the substitution. nixConfig = { @@ -691,40 +691,51 @@ PATH=$(printf '%s' "$PATH" | awk -v RS=: '!seen[$0]++ { printf "%s%s", sep, $0; sep = ":" }') ''; - env = { - # What sh/dispatch.sh checks before a scoped `just check` or `just - # fix`. IN_NIX_SHELL would also pass inside another project's shell. - IN_MOQ_DEV_SHELL = "1"; - - # Local launchers need the exact build without evaluating the flake again. - MOQ_QUEST = "${quest-cli}/bin/quest"; - - PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; - PLAYWRIGHT_VERSION = pkgs.playwright-driver.version; - PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; - - # Where `just obs compile` and `just obs test` look for libobs. Set - # on every platform so the plugin type-checks against the pinned OBS - # release everywhere, rather than whatever the host happens to have. - OBS_INCLUDE_DIR = "${obs-headers}/include/obs"; - - # What `just obs check` compares the two in-repo OBS pins against. - # Exported rather than read back out of nix, so the guard costs a - # variable lookup instead of a nested evaluation of this flake. - OBS_LINKED_VERSION = obs-linked-version; - - # Link libvpx statically for moq-video's `vpx` feature, the shape the - # quest ships: no system codec library at runtime. - VPX_STATIC = "1"; - } - // pkgs.lib.optionalAttrs (!pkgs.stdenv.hostPlatform.isDarwin) { - ALSA_PLUGIN_DIR = "${alsaPlugins}/lib/alsa-lib"; - # Firefox loads these at runtime; harness.ts exposes them only to its process. - PLAYWRIGHT_FIREFOX_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ - pkgs.ffmpeg - pkgs.libpulseaudio - ]; - }; + # moq binaries reject any MOQ_* variable they don't read, so the + # shell must export none. + env = + let + vars = { + # What sh/dispatch.sh checks before a scoped `just check` or `just + # fix`. IN_NIX_SHELL would also pass inside another project's shell. + IN_MOQ_DEV_SHELL = "1"; + + # Local launchers need the exact build without evaluating the flake again. + # QUEST_* belongs to quest itself. + DEV_SHELL_QUEST = "${quest-cli}/bin/quest"; + + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_VERSION = pkgs.playwright-driver.version; + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; + + # Where `just obs compile` and `just obs test` look for libobs. Set + # on every platform so the plugin type-checks against the pinned OBS + # release everywhere, rather than whatever the host happens to have. + OBS_INCLUDE_DIR = "${obs-headers}/include/obs"; + + # What `just obs check` compares the two in-repo OBS pins against. + # Exported rather than read back out of nix, so the guard costs a + # variable lookup instead of a nested evaluation of this flake. + OBS_LINKED_VERSION = obs-linked-version; + + # Link libvpx statically for moq-video's `vpx` feature, the shape the + # quest ships: no system codec library at runtime. + VPX_STATIC = "1"; + } + // pkgs.lib.optionalAttrs (!pkgs.stdenv.hostPlatform.isDarwin) { + ALSA_PLUGIN_DIR = "${alsaPlugins}/lib/alsa-lib"; + # Firefox loads these at runtime; harness.ts exposes them only to its process. + PLAYWRIGHT_FIREFOX_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.ffmpeg + pkgs.libpulseaudio + ]; + }; + moq = builtins.filter (pkgs.lib.hasPrefix "MOQ_") (builtins.attrNames vars); + in + assert pkgs.lib.assertMsg ( + moq == [ ] + ) "dev shell must not export MOQ_* (moq binaries reject them): ${toString moq}"; + vars; }; formatter = pkgs.nixfmt-tree; diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index d11bc22dc22..c43be664ec9 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Socket sessions + +moq.dev/moq/socket now exposes TCP group halves and reliably subscribed track halves beside origin. Regenerate bindings and rebuild the matching native library together. Existing callers need no migration. Socket endpoints require lite-07 or moq-transport on every hop; there is no wire change. + +`GroupProducer.Ready(ctx, size)` is new: it blocks until a frame of `size` bytes fits a window ahead of the fastest reader. Call it before each `WriteFrame` so the writer waits for its fastest reader instead of evicting it, though not for slower readers; a TCP socket stream's producer needs it to behave like TCP. Existing writers are unchanged. + ### `Following.Available` reports the path going offline After the first call, `Available` returns `(nil, nil)` once the followed path goes offline, or, for a `Follow` pinned to an instance, once another instance takes it over. @@ -35,6 +41,22 @@ for { } ``` +### Publishing goes through one `Route` + +`OriginProducer.CreateBroadcast(path)`, `Client.CreateBroadcast`, and `Server.CreateBroadcast` are `Route(path, moq.RouteOptions{})`, and `OriginProducer.Requests(prefix)` is `RoutePrefix(prefix, moq.RouteOptions{})`. Both return a `*Route`, which replaces `*Publication`, `*OriginRequests`, and `*BroadcastPrepared`: + +- `Publication.Broadcast()` is `Route.Broadcast()`, which now also returns an error, on a prefix route. +- `Publication.Announce(moq.AnnounceRoute{...})` is `Announce()`, and re-pricing is `SetCost(cost)`. `Unannounce()` is `Withdraw()`. +- `OriginRequests.RequestedBroadcast(ctx)` is `Route.Requested(ctx)`, which returns `(nil, nil)` once the origin closes; `All(ctx)` and `Cancel()` keep their names. Cancelling ctx no longer retracts the route, only the wait. +- `BroadcastRequest.Prepare()` then `BroadcastPrepared.Publish()` is `BroadcastRequest.Route(options)`: create it hidden to fill it first, then `Announce()`. `BroadcastPrepared.Close()` is `Route.Cancel()`. +- `Route.Instance()` is the stream's instance, minted at creation and kept across `Withdraw` and `Announce`. + +`moq.AnnounceRoute` is `moq.RouteOptions{Hidden, Cost, Instance, Epoch}`, given once at creation. A route is visible at once unless `Hidden`; set it and call `Announce()` after filling the catalog to keep the old create-then-announce order. A stable `Instance` is fixed for the route's lifetime: announcing another one is a new route. + +Keep the `*Route` reachable for as long as the broadcast should stay announced: the collector reclaiming it, or `Cancel()`, retracts it, while `BroadcastProducer.Close()` still ends both. A `*BroadcastProducer` alone no longer keeps it announced. + +`moq.Route`, the received facts in `Announce.Route`, is renamed `moq.RouteInfo`; its fields are unchanged. + ### Catalog renditions carry their warmup ([#7864](https://github.com/moq-dev/moq/pull/7864)) `ffi.MoqVideo` and `ffi.MoqAudio` (`media.Video` and `media.Audio`) gain `Warmup`, the catalog's decoder warmup, nil when absent. `media.NewTrackConsumer` and the audio and video `Subscribe` read it from the rendition and start that much earlier; a negative one is refused with an error. Only unkeyed struct literals change. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index 1550a52120f..16f98f5e61b 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -138,8 +138,8 @@ import ( moqmedia "moq.dev/moq/media" ) -publication, _ := client.CreateBroadcast("room") -broadcast := publication.Broadcast() +route, _ := client.Route("room", moq.RouteOptions{Hidden: true}) +broadcast, _ := route.Broadcast() // Every JSON write is stamped, so the track is timed. JSON is small and repetitive, so // compress it on the wire. scale := uint64(1_000) @@ -148,7 +148,7 @@ status, _ := moqjson.NewSnapshotProducer(broadcast, track, moqjson.SnapshotOptio catalog, _ := moqmedia.NewCatalogProducer(broadcast) now, _ := catalog.Now() _ = status.Update(map[string]any{"viewers": 42}, now) -_ = publication.Announce(moq.AnnounceRoute{}) +_ = route.Announce() ``` Snapshot and window producers take `SnapshotOptions` or `WindowOptions`; the stream producer and diff --git a/go/wrapper/bridge.go b/go/wrapper/bridge.go index 964965f008e..9d8769c457f 100644 --- a/go/wrapper/bridge.go +++ b/go/wrapper/bridge.go @@ -8,6 +8,13 @@ import ( // Hand the layer subpackages the handles behind these types // without exporting them. func init() { + bridge.OriginProducer = func(v any) *ffi.MoqOriginProducer { return v.(*OriginProducer).inner } + bridge.WrapGroupProducer = func(v *ffi.MoqGroupProducer) any { return &GroupProducer{inner: v} } + bridge.WrapGroupConsumer = func(v *ffi.MoqGroupConsumer) any { return &GroupConsumer{inner: v} } + bridge.WrapTrackProducer = func(v *ffi.MoqTrackProducer) any { return &TrackProducer{inner: v} } + bridge.WrapTrackConsumer = func(v *ffi.MoqTrackConsumer) any { return &TrackConsumer{inner: v} } + bridge.TrackInfo = func(v any) (*ffi.MoqTrackInfo, error) { return trackInfoFFI(v.(*TrackInfo)) } + bridge.BroadcastConsumer = func(v any) *ffi.MoqBroadcastConsumer { return v.(*BroadcastConsumer).inner } bridge.TrackRequest = func(v any) *ffi.MoqTrackRequest { return v.(*TrackRequest).inner } bridge.Subscription = func(v any) (*ffi.MoqSubscription, error) { return subscriptionFFI(v.(*Subscription)) } diff --git a/go/wrapper/client.go b/go/wrapper/client.go index 3982bca6ad4..b94a224904a 100644 --- a/go/wrapper/client.go +++ b/go/wrapper/client.go @@ -300,11 +300,11 @@ func Dial(ctx context.Context, url string, config ClientConfig) (*Client, error) return c, nil } -// CreateBroadcast creates an unannounced broadcast at path, invisible to everyone until announced. Announce it after populating tracks. +// Route creates a route at exactly path on the publish origin, owning a new broadcast to fill. // -// See [OriginProducer.CreateBroadcast]. -func (c *Client) CreateBroadcast(path string) (*Publication, error) { - return c.publisher.CreateBroadcast(path) +// See [OriginProducer.Route]. +func (c *Client) Route(path string, options RouteOptions) (*Route, error) { + return c.publisher.Route(path, options) } // Announced lists the paths the remote announces under a pattern scope, as each becomes present or absent. diff --git a/go/wrapper/example_test.go b/go/wrapper/example_test.go index aaba35f5a70..dbc2243939e 100644 --- a/go/wrapper/example_test.go +++ b/go/wrapper/example_test.go @@ -76,7 +76,7 @@ func ExampleClient_Follow() { } // Publish a media track to a relay. -func ExampleClient_CreateBroadcast() { +func ExampleClient_Route() { ctx := context.Background() client, err := moq.Dial(ctx, "https://relay.example.com", moq.ClientConfig{}) @@ -85,17 +85,22 @@ func ExampleClient_CreateBroadcast() { } defer client.Close() - publication, err := client.CreateBroadcast("me/mic") + // Hidden until the catalog is filled; hold the route while it should stay announced. + route, err := client.Route("me/mic", moq.RouteOptions{Hidden: true}) + if err != nil { + log.Fatal(err) + } + defer route.Cancel() + broadcast, err := route.Broadcast() if err != nil { log.Fatal(err) } - broadcast := publication.Broadcast() media, err := moqmedia.NewAudioTrackProducer(broadcast, moqmedia.Named{}, moqmedia.AudioInit{Format: moqmedia.AudioFormatOpus, Data: opusHead()}) if err != nil { log.Fatal(err) } - _ = publication.Announce(moq.AnnounceRoute{}) + _ = route.Announce() // 20 ms in, counted on the Opus 48 kHz sample clock. pts := moq.Timestamp{Value: 960, Scale: 48_000} diff --git a/go/wrapper/internal/bridge/handles.go b/go/wrapper/internal/bridge/handles.go index 48fc0f38573..68421803225 100644 --- a/go/wrapper/internal/bridge/handles.go +++ b/go/wrapper/internal/bridge/handles.go @@ -12,6 +12,19 @@ import ( // moq, so they are set before a subpackage can call them. They take and return // `any` because this package cannot import moq's types without a cycle. var ( + // OriginProducer returns the handle behind a *moq.OriginProducer. + OriginProducer func(any) *ffi.MoqOriginProducer + // WrapGroupProducer wraps a socket write half. + WrapGroupProducer func(*ffi.MoqGroupProducer) any + // WrapGroupConsumer wraps a socket read half. + WrapGroupConsumer func(*ffi.MoqGroupConsumer) any + // WrapTrackProducer wraps a socket write half. + WrapTrackProducer func(*ffi.MoqTrackProducer) any + // WrapTrackConsumer retains a socket's subscribed read half. + WrapTrackConsumer func(*ffi.MoqTrackConsumer) any + // TrackInfo converts a *moq.TrackInfo to the generated record. + TrackInfo func(any) (*ffi.MoqTrackInfo, error) + // BroadcastProducer returns the handle behind a *moq.BroadcastProducer. BroadcastProducer func(any) *ffi.MoqBroadcastProducer // TrackProducer returns the handle behind a *moq.TrackProducer. diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 3f64bdcdc39..268a5cf20e1 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -30,6 +30,22 @@ func newOrigin(t *testing.T) *moq.OriginProducer { return origin } +// newRoute creates an exact route on origin with its broadcast, kept reachable +// until the test ends: the collector reclaiming it would retract the route. +func newRoute(t *testing.T, origin *moq.OriginProducer, path string, options moq.RouteOptions) (*moq.Route, *moq.BroadcastProducer) { + t.Helper() + route, err := origin.Route(path, options) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { runtime.KeepAlive(route) }) + broadcast, err := route.Broadcast() + if err != nil { + t.Fatal(err) + } + return route, broadcast +} + // opusHead builds a valid OpusHead init buffer (RFC 7845): 48 kHz, 2 channels. func opusHead() []byte { buf := []byte("OpusHead") @@ -46,11 +62,14 @@ func TestDynamicBroadcastRequest(t *testing.T) { defer cancel() origin := newOrigin(t) - dynamic, err := origin.Requests("") + dynamic, err := origin.RoutePrefix("", moq.RouteOptions{}) if err != nil { t.Fatal(err) } defer dynamic.Cancel() + if _, err := dynamic.Broadcast(); err == nil { + t.Fatal("a prefix route owns no broadcast") + } type result struct { broadcast *moq.BroadcastConsumer @@ -62,7 +81,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { requested <- result{broadcast: broadcast, err: err} }() - request, err := dynamic.RequestedBroadcast(ctx) + request, err := dynamic.Requested(ctx) if err != nil { t.Fatal(err) } @@ -74,18 +93,26 @@ func TestDynamicBroadcastRequest(t *testing.T) { t.Fatalf("path = %q, want %q", path, "dynamic/broadcast") } - prepared, err := request.Prepare() + served, err := request.Route(moq.RouteOptions{Hidden: true}) if err != nil { t.Fatal(err) } - track, err := prepared.Broadcast().PublishTrack("status", timedInfo()) + defer runtime.KeepAlive(served) + if served.Instance() == dynamic.Instance() { + t.Fatal("a job's route serves a fresh instance") + } + broadcast, err := served.Broadcast() if err != nil { t.Fatal(err) } - served, err := prepared.Publish() + track, err := broadcast.PublishTrack("status", timedInfo()) if err != nil { t.Fatal(err) } + // Only announcing answers the requester. + if err := served.Announce(); err != nil { + t.Fatal(err) + } var res result select { @@ -118,13 +145,20 @@ func TestDynamicBroadcastRequest(t *testing.T) { if err := track.Finish(); err != nil { t.Fatal(err) } - if err := served.Broadcast().Close(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } - // The publication fills the prepared broadcast, so closing one closes the other. - if _, err := prepared.Broadcast().PublishTrack("late", nil); !errors.Is(err, moq.ErrClosed) { + // The route hands out one broadcast, so closing it ends the route too. + again, err := served.Broadcast() + if err != nil { + t.Fatal(err) + } + if _, err := again.PublishTrack("late", nil); !errors.Is(err, moq.ErrClosed) { t.Fatalf("PublishTrack after close = %v, want ErrClosed", err) } + if err := served.Announce(); !errors.Is(err, moq.ErrClosed) { + t.Fatalf("Announce after close = %v, want ErrClosed", err) + } } func TestPublishAudioLifecycle(t *testing.T) { @@ -251,11 +285,7 @@ func TestAudioConsumerDelay(t *testing.T) { defer cancel() origin := newOrigin(t) - publication, err := origin.CreateBroadcast("audio-delay") - if err != nil { - t.Fatal(err) - } - broadcast := publication.Broadcast() + route, broadcast := newRoute(t, origin, "audio-delay", moq.RouteOptions{Hidden: true}) track := "mic" catalogProducer, err := moqmedia.NewCatalogProducer(broadcast) if err != nil { @@ -270,7 +300,7 @@ func TestAudioConsumerDelay(t *testing.T) { if err != nil { t.Fatal(err) } - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { + if err := route.Announce(); err != nil { t.Fatal(err) } @@ -417,11 +447,7 @@ func testDecodeVideoFrame(t *testing.T, surface bool) { defer cancel() origin := newOrigin(t) - publication, err := origin.CreateBroadcast("video-decode-frame") - if err != nil { - t.Fatal(err) - } - broadcast := publication.Broadcast() + route, broadcast := newRoute(t, origin, "video-decode-frame", moq.RouteOptions{Hidden: true}) track := "camera" catalogProducer, err := moqmedia.NewCatalogProducer(broadcast) if err != nil { @@ -436,7 +462,7 @@ func testDecodeVideoFrame(t *testing.T, surface bool) { if err != nil { t.Fatal(err) } - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { + if err := route.Announce(); err != nil { t.Fatal(err) } @@ -758,16 +784,12 @@ func TestLocalPublishConsumeAudio(t *testing.T) { defer cancel() origin := newOrigin(t) - publication, err := origin.CreateBroadcast("live") - if err != nil { - t.Fatal(err) - } - broadcast := publication.Broadcast() + route, broadcast := newRoute(t, origin, "live", moq.RouteOptions{Hidden: true}) media, err := moqmedia.NewAudioTrackProducer(broadcast, moqmedia.Named{}, moqmedia.AudioInit{Format: moqmedia.AudioFormatOpus, Data: opusHead()}) if err != nil { t.Fatal(err) } - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { + if err := route.Announce(); err != nil { t.Fatal(err) } @@ -1302,7 +1324,7 @@ func TestConsumerCancelConcurrent(t *testing.T) { // that one request rather than the consumer it was made on. func TestRequestCancelKeepsTheOrigin(t *testing.T) { origin := newOrigin(t) - dynamic, err := origin.Requests("") + dynamic, err := origin.RoutePrefix("", moq.RouteOptions{}) if err != nil { t.Fatal(err) } @@ -1321,7 +1343,7 @@ func TestRequestCancelKeepsTheOrigin(t *testing.T) { // Take the request but never answer it, so the cancel lands on a call that is // genuinely parked rather than one that has not started. - pending, err := dynamic.RequestedBroadcast(waitCtx) + pending, err := dynamic.Requested(waitCtx) if err != nil { t.Fatal(err) } @@ -1345,19 +1367,15 @@ func TestRequestCancelKeepsTheOrigin(t *testing.T) { resolved <- err }() - next, err := dynamic.RequestedBroadcast(waitCtx) + next, err := dynamic.Requested(waitCtx) if err != nil { t.Fatal(err) } - prepared, err := next.Prepare() + served, err := next.Route(moq.RouteOptions{}) if err != nil { t.Fatal(err) } - served, err := prepared.Publish() - if err != nil { - t.Fatal(err) - } - defer served.Broadcast().Close() + defer runtime.KeepAlive(served) select { case err := <-resolved: if err != nil { @@ -1563,11 +1581,7 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { defer cancel() origin := newOrigin(t) - publication, err := origin.CreateBroadcast("live") - if err != nil { - t.Fatal(err) - } - broadcast := publication.Broadcast() + route, broadcast := newRoute(t, origin, "live", moq.RouteOptions{Hidden: true}) if _, err := broadcast.PublishTrack("events", nil); err != nil { t.Fatal(err) } @@ -1576,7 +1590,10 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { t.Fatal("an unannounced broadcast must be unroutable") } - if err := publication.Announce(moq.AnnounceRoute{Cost: 3}); err != nil { + if err := route.SetCost(3); err != nil { + t.Fatal(err) + } + if err := route.Announce(); err != nil { t.Fatal(err) } announced, err := consumer.Announced(moq.AnnounceOptions{}) @@ -1589,21 +1606,23 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { t.Fatalf("announce: ann=%+v", ann) } - if err := publication.Unannounce(); err != nil { + if err := route.Withdraw(); err != nil { t.Fatal(err) } event := nextRoute(t, ctx, announced) if retracted, ok := event.(moq.AnnounceEventAbsent); !ok || retracted.Announce.Prefix != "live" { - t.Fatalf("unannounce: event=%+v", event) + t.Fatalf("withdraw: event=%+v", event) } if _, err := consumer.Request(ctx, "live", nil); err == nil { t.Fatal("an unannounced broadcast must be unroutable") } - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { + if err := route.Announce(); err != nil { t.Fatal(err) } - nextAnnounced(t, ctx, announced) + if ann := nextAnnounced(t, ctx, announced); ann.Route.Instance != route.Instance() { + t.Fatalf("reannounce: ann=%+v, want instance %s", ann, route.Instance()) + } if _, err := consumer.Request(ctx, "live", nil); err != nil { t.Fatal(err) } @@ -1612,37 +1631,28 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { } } -// A publication outliving its closed broadcast cannot announce or withdraw the -// broadcast that replaced it at the same path. -func TestStalePublicationLeavesItsReplacementAlone(t *testing.T) { +// A route outliving its closed broadcast cannot announce, re-price, or withdraw +// the route that replaced it at the same path. +func TestStaleRouteLeavesItsReplacementAlone(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() origin := newOrigin(t) - stale, err := origin.CreateBroadcast("live") - if err != nil { - t.Fatal(err) - } - if err := stale.Announce(moq.AnnounceRoute{}); err != nil { - t.Fatal(err) - } - if err := stale.Broadcast().Close(); err != nil { + stale, broadcast := newRoute(t, origin, "live", moq.RouteOptions{}) + if err := broadcast.Close(); err != nil { t.Fatal(err) } - replacement, err := origin.CreateBroadcast("live") - if err != nil { - t.Fatal(err) - } - defer replacement.Broadcast().Close() - if err := replacement.Announce(moq.AnnounceRoute{}); err != nil { - t.Fatal(err) - } - if err := stale.Announce(moq.AnnounceRoute{}); !errors.Is(err, moq.ErrClosed) { + _, replacement := newRoute(t, origin, "live", moq.RouteOptions{}) + defer replacement.Close() + if err := stale.Announce(); !errors.Is(err, moq.ErrClosed) { t.Fatalf("stale Announce = %v, want ErrClosed", err) } - if err := stale.Unannounce(); !errors.Is(err, moq.ErrClosed) { - t.Fatalf("stale Unannounce = %v, want ErrClosed", err) + if err := stale.SetCost(1); !errors.Is(err, moq.ErrClosed) { + t.Fatalf("stale SetCost = %v, want ErrClosed", err) + } + if err := stale.Withdraw(); err != nil { + t.Fatalf("stale Withdraw = %v, want a no-op", err) } if _, err := origin.Consume().Request(ctx, "live", nil); err != nil { t.Fatalf("Request = %v, want the replacement", err) @@ -1661,20 +1671,8 @@ func TestAnnouncedPatternCaptures(t *testing.T) { } defer announced.Cancel() - audio, err := origin.CreateBroadcast("room/alice/audio") - if err != nil { - t.Fatal(err) - } - if err := audio.Announce(moq.AnnounceRoute{}); err != nil { - t.Fatal(err) - } - chat, err := origin.CreateBroadcast("room/alice/chat") - if err != nil { - t.Fatal(err) - } - if err := chat.Announce(moq.AnnounceRoute{}); err != nil { - t.Fatal(err) - } + newRoute(t, origin, "room/alice/audio", moq.RouteOptions{}) + newRoute(t, origin, "room/alice/chat", moq.RouteOptions{}) update := nextAnnounced(t, ctx, announced) if update.Prefix != "room/alice/chat" { @@ -1699,13 +1697,7 @@ func TestAnnouncedExactFilterCapturesEmpty(t *testing.T) { } defer announced.Cancel() - chat, err := origin.CreateBroadcast("room/alice/chat") - if err != nil { - t.Fatal(err) - } - if err := chat.Announce(moq.AnnounceRoute{}); err != nil { - t.Fatal(err) - } + newRoute(t, origin, "room/alice/chat", moq.RouteOptions{}) update := nextAnnounced(t, ctx, announced) if update.Prefix != "room/alice/chat" { @@ -1716,9 +1708,9 @@ func TestAnnouncedExactFilterCapturesEmpty(t *testing.T) { } } -// A broadcast mints its own instance and keeps it across re-announcing. Another -// instance keeps the path present in the listing and reaches only followers. -func TestInstanceChangeReachesFollowers(t *testing.T) { +// A route mints its own instance and keeps it across re-pricing. Another route +// keeps the path present in the listing and reaches only followers. +func TestNewRouteReachesFollowers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() @@ -1735,34 +1727,30 @@ func TestInstanceChangeReachesFollowers(t *testing.T) { } defer following.Cancel() - publication, err := origin.CreateBroadcast("instance") - if err != nil { - t.Fatal(err) - } - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { - t.Fatal(err) - } + route, _ := newRoute(t, origin, "instance", moq.RouteOptions{}) minted := nextAnnounced(t, ctx, announced).Route.Instance - if minted == "" { - t.Fatal("present instance is empty, want a minted one") + if minted == "" || minted != route.Instance() { + t.Fatalf("present instance = %q, want the minted %q", minted, route.Instance()) } if _, err := following.Available(ctx); err != nil { t.Fatal(err) } - if err := publication.Announce(moq.AnnounceRoute{Cost: 1}); err != nil { + if err := route.SetCost(1); err != nil { t.Fatal(err) } stable := "cam" - if err := publication.Announce(moq.AnnounceRoute{Instance: &stable, Epoch: 1}); err != nil { - t.Fatal(err) - } + second, _ := newRoute(t, origin, "instance", moq.RouteOptions{Instance: &stable, Epoch: 1}) if _, err := following.Available(ctx); err != nil { t.Fatalf("follow after the instance change: %v", err) } - // The listing's next event is the absence, so neither re-announce reached it. - if err := publication.Unannounce(); err != nil { + // The listing's next event is the absence, so neither the re-price nor the new + // route reached it. + if err := route.Withdraw(); err != nil { + t.Fatal(err) + } + if err := second.Withdraw(); err != nil { t.Fatal(err) } event := nextRoute(t, ctx, announced) @@ -1771,8 +1759,8 @@ func TestInstanceChangeReachesFollowers(t *testing.T) { } empty := "" - if err := publication.Announce(moq.AnnounceRoute{Instance: &empty}); !errors.Is(err, moq.ErrInvalidInstance) { - t.Fatalf("Announce(empty instance) = %v, want ErrInvalidInstance", err) + if _, err := origin.Route("refused", moq.RouteOptions{Instance: &empty}); !errors.Is(err, moq.ErrInvalidInstance) { + t.Fatalf("Route(empty instance) = %v, want ErrInvalidInstance", err) } } @@ -1788,25 +1776,19 @@ func TestFollowReportsOffline(t *testing.T) { } defer following.Cancel() - publication, err := origin.CreateBroadcast("offline") - if err != nil { - t.Fatal(err) - } - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { - t.Fatal(err) - } + route, _ := newRoute(t, origin, "offline", moq.RouteOptions{}) if broadcast, err := following.Available(ctx); err != nil || broadcast == nil { t.Fatalf("first Available = %v, %v; want a broadcast", broadcast, err) } - if err := publication.Unannounce(); err != nil { + if err := route.Withdraw(); err != nil { t.Fatal(err) } if broadcast, err := following.Available(ctx); err != nil || broadcast != nil { - t.Fatalf("Available after unannounce = %v, %v; want nil", broadcast, err) + t.Fatalf("Available after withdraw = %v, %v; want nil", broadcast, err) } - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { + if err := route.Announce(); err != nil { t.Fatal(err) } if broadcast, err := following.Available(ctx); err != nil || broadcast == nil { @@ -1827,21 +1809,13 @@ func TestPinnedFollowReportsTakeoverOffline(t *testing.T) { } defer following.Cancel() - publication, err := origin.CreateBroadcast("pinned") - if err != nil { - t.Fatal(err) - } - if err := publication.Announce(moq.AnnounceRoute{Instance: &pinned}); err != nil { - t.Fatal(err) - } + newRoute(t, origin, "pinned", moq.RouteOptions{Instance: &pinned}) if broadcast, err := following.Available(ctx); err != nil || broadcast == nil { t.Fatalf("first Available = %v, %v; want a broadcast", broadcast, err) } other := "other" - if err := publication.Announce(moq.AnnounceRoute{Instance: &other, Epoch: 1}); err != nil { - t.Fatal(err) - } + newRoute(t, origin, "pinned", moq.RouteOptions{Instance: &other, Epoch: 1}) if broadcast, err := following.Available(ctx); err != nil || broadcast != nil { t.Fatalf("Available after takeover = %v, %v; want nil", broadcast, err) } @@ -1878,7 +1852,7 @@ func TestDynamicServesARequestUnderAPrefix(t *testing.T) { defer cancel() origin := newOrigin(t) - dynamic, err := origin.Requests("live") + dynamic, err := origin.RoutePrefix("live", moq.RouteOptions{}) if err != nil { t.Fatal(err) } @@ -1891,7 +1865,7 @@ func TestDynamicServesARequestUnderAPrefix(t *testing.T) { done <- err }() - request, err := dynamic.RequestedBroadcast(ctx) + request, err := dynamic.Requested(ctx) if err != nil { t.Fatal(err) } @@ -1899,26 +1873,22 @@ func TestDynamicServesARequestUnderAPrefix(t *testing.T) { if err != nil || path != "live/cam" { t.Fatalf("path = %q err=%v", path, err) } - prepared, err := request.Prepare() - if err != nil { - t.Fatal(err) - } - served, err := prepared.Publish() + served, err := request.Route(moq.RouteOptions{}) if err != nil { t.Fatal(err) } - defer served.Broadcast().Close() + defer runtime.KeepAlive(served) if err := <-done; err != nil { t.Fatal(err) } } -func TestClosingAPreparedAnswerRefusesTheRequest(t *testing.T) { +func TestCancellingAHiddenAnswerRefusesTheRequest(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() origin := newOrigin(t) - dynamic, err := origin.Requests("live") + dynamic, err := origin.RoutePrefix("live", moq.RouteOptions{}) if err != nil { t.Fatal(err) } @@ -1931,15 +1901,15 @@ func TestClosingAPreparedAnswerRefusesTheRequest(t *testing.T) { done <- err }() - request, err := dynamic.RequestedBroadcast(ctx) + request, err := dynamic.Requested(ctx) if err != nil { t.Fatal(err) } - prepared, err := request.Prepare() + hidden, err := request.Route(moq.RouteOptions{Hidden: true}) if err != nil { t.Fatal(err) } - prepared.Close() + hidden.Cancel() select { case err := <-done: diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index 0abeae0c06f..c3d65e0954b 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -13,7 +13,7 @@ import ( // consume sink for a full-duplex peer. // // There is no Close: the origin ends once the collector reaches every owner -// (each producer, published broadcast, and [OriginRequests]), and its consumers +// (each producer and [Route]), and its consumers // then fail with [ErrClosed]. Keep an owner reachable for as long as the origin // should live. type OriginProducer struct { @@ -35,95 +35,122 @@ func (o *OriginProducer) Consume() *OriginConsumer { return &OriginConsumer{inner: o.inner.Consume()} } -// Requests advertises prefix and serves the requests beneath it. +// Route creates a route at exactly path, owning a new broadcast to fill. // -// A route claims prefix and every path beneath it ("" claims every path). A -// service that only serves some of them rejects the rest as they are requested. -// Create, Requests if tracks are served on demand, populate, then -// [Publication.Announce]. -func (o *OriginProducer) Requests(prefix string) (*OriginRequests, error) { - inner, err := o.inner.Requests(prefix) +// The route is visible at once unless options.Hidden: a hidden route is +// invisible and unroutable, for this origin's consumers and peers alike, until +// [Route.Announce], so create it hidden to fill the catalog first. It serves a +// fresh instance, kept across [Route.Withdraw] and [Route.Announce], unless +// options names one. Keep the route reachable: [Route.Cancel], or the collector +// reclaiming it, retracts it, and [BroadcastProducer.Close] ends both. +func (o *OriginProducer) Route(path string, options RouteOptions) (*Route, error) { + inner, err := o.inner.Route(path, options) if err != nil { return nil, err } - return &OriginRequests{inner: inner}, nil + return newRoute(inner), nil } -// CreateBroadcast creates a broadcast at the given path, returning its publication. +// RoutePrefix creates a route over prefix that serves the requests beneath it. // -// The broadcast is invisible and unroutable, for this origin's consumers and -// peers alike, until [Publication.Announce]. Announce it after populating -// tracks. [BroadcastProducer.Close] ends it for good; dropping the last handle, -// the publication included, does the same. -func (o *OriginProducer) CreateBroadcast(path string) (*Publication, error) { - inner, err := o.inner.CreateBroadcast(path) +// A route claims prefix and every path beneath it ("" claims every path). A +// service that only serves some of them rejects the rest as they are requested. +// Answer each [Route.Requested] request, and keep the route reachable while it +// should stay advertised. +func (o *OriginProducer) RoutePrefix(prefix string, options RouteOptions) (*Route, error) { + inner, err := o.inner.RoutePrefix(prefix, options) if err != nil { return nil, err } - return newPublication(inner), nil + return newRoute(inner), nil } -// Publication is a broadcast published on an origin: the broadcast to fill and -// its announcement. +// Route is a route on an origin: an exact route owning one broadcast, or a +// prefix route yielding the requests beneath it. // -// The collector reclaiming it does not retract the route; [BroadcastProducer.Close] does. -type Publication struct { - inner *ffi.MoqPublication +// [Route.Cancel], or the collector reclaiming it, retracts the route, as does +// closing an exact route's broadcast; a [BroadcastProducer] alone does not keep +// it announced. +type Route struct { + inner *ffi.MoqRoute broadcast *BroadcastProducer } -func newPublication(inner *ffi.MoqPublication) *Publication { - return &Publication{inner: inner, broadcast: &BroadcastProducer{inner: inner.Broadcast()}} +func newRoute(inner *ffi.MoqRoute) *Route { + route := &Route{inner: inner} + // A prefix route owns no broadcast. + if broadcast, err := inner.Broadcast(); err == nil { + route.broadcast = &BroadcastProducer{inner: broadcast} + } + return route +} + +// Broadcast returns the broadcast an exact route owns, the same one on every +// call, or an error on a prefix route, which owns none. +func (r *Route) Broadcast() (*BroadcastProducer, error) { + if r.broadcast == nil { + _, err := r.inner.Broadcast() + return nil, err + } + return r.broadcast, nil } -// Broadcast returns the broadcast to fill, the same one on every call. -func (p *Publication) Broadcast() *BroadcastProducer { - return p.broadcast +// Instance returns the stream this route serves, minted at creation unless +// [RouteOptions] named one. +func (r *Route) Instance() string { + return r.inner.InstanceId() } -// Announce advertises the broadcast's exact path as a route. -// -// Announcing again re-prices the route in place. Until announced, the -// broadcast is invisible and unroutable for local consumers and peers alike. -// Errors with ErrClosed once the broadcast is closed. -func (p *Publication) Announce(route AnnounceRoute) error { - return p.inner.Announce(route) +// Announce makes the route visible, or keeps it so. A route from +// [BroadcastRequest.Route] also answers the requesters waiting on its path. +// Errors with ErrClosed once its broadcast is closed. +func (r *Route) Announce() error { + return r.inner.Announce() } -// Unannounce retracts the broadcast's exact-path advertisement, if any, from -// local consumers and peers alike. Tracks already in flight carry on, and -// Announce brings it back. Errors with ErrClosed once the broadcast is closed. -func (p *Publication) Unannounce() error { - return p.inner.Unannounce() +// Withdraw hides the route from local consumers and peers alike, keeping its +// broadcast and instance. Tracks already in flight carry on, and Announce +// brings the same stream back. +func (r *Route) Withdraw() error { + return r.inner.Withdraw() } -// OriginRequests streams requests for paths with no existing exact-path broadcast. -type OriginRequests struct { - inner *ffi.MoqOriginRequests +// SetCost re-prices the route in place: lower wins among routes covering the +// same prefix. A hidden route keeps the price for when it next announces. +func (r *Route) SetCost(cost uint64) error { + return r.inner.SetCost(cost) } -// RequestedBroadcast blocks until a consumer requests a path with no existing -// exact-path broadcast. -func (d *OriginRequests) RequestedBroadcast(ctx context.Context) (*BroadcastRequest, error) { - inner, err := bridge.CallHandle(ctx, d.inner.Cancel, d.inner.RequestedBroadcast) - if err != nil { +// Requested blocks until a consumer requests a path beneath this prefix route, +// returning (nil, nil) once the origin closes, and at once on an exact route. +// +// Cancelling ctx abandons only this wait: the route stays advertised. +func (r *Route) Requested(ctx context.Context) (*BroadcastRequest, error) { + inner, err := bridge.CallRelease(ctx, nil, r.inner.Requested, func(request **ffi.MoqBroadcastRequest) { + if request != nil { + (*request).Destroy() + } + }) + if err != nil || inner == nil { return nil, err } - return &BroadcastRequest{inner: inner}, nil + return &BroadcastRequest{inner: *inner}, nil } -// All ranges over requested broadcasts until the stream errors or the loop -// breaks. -func (d *OriginRequests) All(ctx context.Context) iter.Seq2[*BroadcastRequest, error] { - return bridge.Seq(ctx, d.RequestedBroadcast) +// All ranges over requested broadcasts until the route ends, a call errors, or +// the loop breaks. +func (r *Route) All(ctx context.Context) iter.Seq2[*BroadcastRequest, error] { + return bridge.Seq(ctx, r.Requested) } -// Cancel stops serving requested broadcasts and retracts the route. -func (d *OriginRequests) Cancel() { - d.inner.Cancel() +// Cancel retracts the route now, rather than when the collector reclaims it. +// Pending requests are rejected and later calls fail with ErrCancelled. An exact +// route's broadcast lives on until [BroadcastProducer.Close]. +func (r *Route) Cancel() { + r.inner.Cancel() } -// BroadcastRequest is a requested broadcast that has not been accepted yet. +// BroadcastRequest is a requested broadcast that has not been answered yet. type BroadcastRequest struct { inner *ffi.MoqBroadcastRequest } @@ -133,13 +160,17 @@ func (r *BroadcastRequest) Path() (string, error) { return r.inner.Path() } -// Prepare starts answering as a new stream under a fresh instance, hidden until published. -func (r *BroadcastRequest) Prepare() (*BroadcastPrepared, error) { - inner, err := r.inner.Prepare() +// Route answers with the path's own exact route, owning a new broadcast. +// +// It serves a fresh instance unless options names one. Created visible, it +// announces now and answers the waiting requesters; created hidden, they wait +// until [Route.Announce], so the job can fill its catalog first. +func (r *BroadcastRequest) Route(options RouteOptions) (*Route, error) { + inner, err := r.inner.Route(options) if err != nil { return nil, err } - return &BroadcastPrepared{inner: inner}, nil + return newRoute(inner), nil } // Serve answers with held content under the prefix route's continuous sequence space, @@ -153,31 +184,6 @@ func (r *BroadcastRequest) Reject(errorCode uint16) error { return r.inner.Reject(errorCode) } -// BroadcastPrepared is a claim answer being filled before announcement. -type BroadcastPrepared struct { - inner *ffi.MoqBroadcastPrepared -} - -// Broadcast returns the unannounced broadcast to fill before publishing; only Publish announces it. -func (p *BroadcastPrepared) Broadcast() *BroadcastProducer { - return &BroadcastProducer{inner: p.inner.Broadcast()} -} - -// Publish announces the answer and resolves every waiting requester, returning -// its publication. -func (p *BroadcastPrepared) Publish() (*Publication, error) { - inner, err := p.inner.Publish() - if err != nil { - return nil, err - } - return newPublication(inner), nil -} - -// Close abandons the answer if it was not published, refusing every waiting requester. -func (p *BroadcastPrepared) Close() { - p.inner.Destroy() -} - // OriginConsumer discovers and requests broadcasts published to an origin. type OriginConsumer struct { inner *ffi.MoqOriginConsumer diff --git a/go/wrapper/origin_internal_test.go b/go/wrapper/origin_internal_test.go index d0c9fd04b4e..e3f2de5544b 100644 --- a/go/wrapper/origin_internal_test.go +++ b/go/wrapper/origin_internal_test.go @@ -35,15 +35,15 @@ func TestOriginEndsWithItsProducer(t *testing.T) { } } -// An OriginRequests owns its origin: once the collector finalizes the last +// A prefix Route owns its origin: once the collector finalizes the last // OriginProducer, the route still serves and a consumer made earlier still // resolves through it. -func TestRequestsKeepTheOrigin(t *testing.T) { +func TestRouteKeepsTheOrigin(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() origin := NewOriginProducer() - dynamic, err := origin.Requests("") + dynamic, err := origin.RoutePrefix("", RouteOptions{}) if err != nil { t.Fatal(err) } @@ -62,19 +62,15 @@ func TestRequestsKeepTheOrigin(t *testing.T) { requested <- result{broadcast: broadcast, err: err} }() - request, err := dynamic.RequestedBroadcast(ctx) - if err != nil { - t.Fatalf("RequestedBroadcast err = %v, want a request", err) - } - prepared, err := request.Prepare() - if err != nil { - t.Fatal(err) + request, err := dynamic.Requested(ctx) + if err != nil || request == nil { + t.Fatalf("Requested = (%v, %v), want a request", request, err) } - served, err := prepared.Publish() + served, err := request.Route(RouteOptions{}) if err != nil { t.Fatal(err) } - defer func() { _ = served.Broadcast().Close() }() + defer func() { _ = served.broadcast.Close() }() var res result select { @@ -87,34 +83,37 @@ func TestRequestsKeepTheOrigin(t *testing.T) { } } -// Releasing a publication, which is what its finalizer does, keeps the route: -// only closing the broadcast retracts it. -func TestReleasingAPublicationKeepsItsRoute(t *testing.T) { +// Releasing a route, which is what its finalizer does, retracts it: the +// broadcast alone keeps nothing announced, though it lives on until closed. +func TestReleasingARouteRetractsIt(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() origin := NewOriginProducer() consumer := origin.Consume() - publication, err := origin.CreateBroadcast("live") + route, err := origin.Route("live", RouteOptions{}) if err != nil { t.Fatal(err) } - if err := publication.Announce(AnnounceRoute{}); err != nil { + broadcast, err := route.Broadcast() + if err != nil { t.Fatal(err) } - broadcast := publication.Broadcast() - if broadcast != publication.Broadcast() { + if again, _ := route.Broadcast(); again != broadcast { t.Fatal("Broadcast must return the same producer on every call") } - publication.inner.Destroy() - if _, err := consumer.Request(ctx, "live", nil); err != nil { - t.Fatalf("Request = %v, want the released publication's broadcast", err) + t.Fatalf("Request = %v, want the route's broadcast", err) + } + route.inner.Destroy() + + if _, err := consumer.Request(ctx, "live", nil); err == nil { + t.Fatal("a released route must be unroutable") + } + if _, err := broadcast.PublishTrack("data", nil); err != nil { + t.Fatalf("PublishTrack = %v, want the broadcast still open", err) } if err := broadcast.Close(); err != nil { t.Fatal(err) } - if _, err := consumer.Request(ctx, "live", nil); err == nil { - t.Fatal("a closed broadcast must be unroutable") - } } diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 7abebe48368..013ac28ad76 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -10,8 +10,8 @@ import ( ) // BroadcastProducer publishes a collection of tracks. Create one at a path with -// [OriginProducer.CreateBroadcast] (or [Client.CreateBroadcast]) and take it -// from the [Publication], then publish tracks onto it. +// [OriginProducer.Route] (or [Client.Route]) and take it from the [Route], then +// publish tracks onto it. type BroadcastProducer struct { inner *ffi.MoqBroadcastProducer } @@ -19,7 +19,7 @@ type BroadcastProducer struct { // NewBroadcastProducer creates a standalone broadcast, not attached to any // origin: pass its [BroadcastProducer.Consume] result to [BroadcastRequest.Serve] // for a dynamic request, or use it for local pub/sub. To publish at a path, use -// [OriginProducer.CreateBroadcast]. +// [OriginProducer.Route]. func NewBroadcastProducer() (*BroadcastProducer, error) { inner, err := ffi.NewMoqBroadcastProducer() if err != nil { @@ -265,7 +265,18 @@ func (g *GroupProducer) Consume() (*GroupConsumer, error) { return &GroupConsumer{inner: inner}, nil } -// WriteFrame appends frame to the group. +// Ready blocks until a frame of size bytes fits a window ahead of the fastest +// reader. Call it before each WriteFrame so that reader paces the writer, as TCP +// backpressure does, instead of the group shedding frames it has not taken, which +// fails it with Evicted; slower readers are not waited on. It also waits until a reader holds +// the group, and returns once the group is closed so the next write reports why. +// It fails with Closed after Abort. +func (g *GroupProducer) Ready(ctx context.Context, size uint64) error { + return g.inner.Ready(ctx, size) +} + +// WriteFrame appends frame to the group. It never blocks: without Ready, a writer +// that outpaces its readers sheds the front of the group instead. func (g *GroupProducer) WriteFrame(frame Frame) error { return g.inner.WriteFrame(frame) } diff --git a/go/wrapper/reconnect_test.go b/go/wrapper/reconnect_test.go index 398fe4f8a66..0926f488c1e 100644 --- a/go/wrapper/reconnect_test.go +++ b/go/wrapper/reconnect_test.go @@ -184,11 +184,15 @@ func TestReconnectAcrossRelayRestart(t *testing.T) { publisher := dialWorker(t, ctx, url) defer publisher.Close() - publication, err := publisher.CreateBroadcast("live") + route, err := publisher.Route("live", moq.RouteOptions{Hidden: true}) + if err != nil { + t.Fatal(err) + } + defer route.Cancel() + broadcast, err := route.Broadcast() if err != nil { t.Fatal(err) } - broadcast := publication.Broadcast() defer broadcast.Close() track, err := broadcast.PublishTrack("data", timedInfo()) @@ -196,7 +200,7 @@ func TestReconnectAcrossRelayRestart(t *testing.T) { t.Fatal(err) } defer track.Finish() - if err := publication.Announce(moq.AnnounceRoute{}); err != nil { + if err := route.Announce(); err != nil { t.Fatal(err) } diff --git a/go/wrapper/records.go b/go/wrapper/records.go index 3dbf470e4ca..30d7456af97 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -208,6 +208,9 @@ type ConnectionStats struct { PacketsLost *uint64 // Version is the negotiated protocol version, spelled like ClientConfig.Versions. Version *string + // DatagramsUnrouted counts datagrams received for a subscription with no datagram + // track; nil on a version without datagrams. + DatagramsUnrouted *uint64 } func connectionStatsFromFFI(s ffi.MoqConnectionStats) ConnectionStats { @@ -221,6 +224,7 @@ func connectionStatsFromFFI(s ffi.MoqConnectionStats) ConnectionStats { PacketsReceived: s.PacketsReceived, PacketsLost: s.PacketsLost, Version: s.Version, + DatagramsUnrouted: s.DatagramsUnrouted, } } diff --git a/go/wrapper/runtime_bench_test.go b/go/wrapper/runtime_bench_test.go index 5507f55d70c..0d01fb69477 100644 --- a/go/wrapper/runtime_bench_test.go +++ b/go/wrapper/runtime_bench_test.go @@ -150,11 +150,15 @@ func benchThroughput(b *testing.B, c benchCase) { origin := moq.NewOriginProducerWithConfig(moq.OriginConfig{CacheCapacityBytes: &cacheBytes}) // The origin owns the cache policy; keep it until the case ends. b.Cleanup(func() { runtime.KeepAlive(origin) }) - publication, err := origin.CreateBroadcast("bench") + route, err := origin.Route("bench", moq.RouteOptions{Hidden: true}) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { runtime.KeepAlive(route) }) + broadcast, err := route.Broadcast() if err != nil { b.Fatal(err) } - broadcast := publication.Broadcast() b.Cleanup(func() { _ = broadcast.Close() }) tracks := make([]*moq.TrackProducer, c.publishers) consumers := make([]*moq.TrackConsumer, 0, c.publishers*c.subscribers) diff --git a/go/wrapper/server.go b/go/wrapper/server.go index 2247aa52cd0..c81e5e30029 100644 --- a/go/wrapper/server.go +++ b/go/wrapper/server.go @@ -215,14 +215,14 @@ func (s *Server) CertFingerprints() ([]string, error) { return s.inner.CertFingerprints() } -// CreateBroadcast creates an unannounced broadcast at path, invisible to everyone until announced. Announce it after populating tracks. +// Route creates a route at exactly path on the publish origin, owning a new broadcast to fill. // -// See [OriginProducer.CreateBroadcast]. -func (s *Server) CreateBroadcast(path string) (*Publication, error) { +// See [OriginProducer.Route]. +func (s *Server) Route(path string, options RouteOptions) (*Route, error) { if s.publishOrigin == nil { return nil, ErrNoPublishOrigin } - return s.publishOrigin.CreateBroadcast(path) + return s.publishOrigin.Route(path, options) } // Accept returns the next incoming request, or (nil, nil) when the server stops. diff --git a/go/wrapper/socket/socket.go b/go/wrapper/socket/socket.go new file mode 100644 index 00000000000..4e259a77513 --- /dev/null +++ b/go/wrapper/socket/socket.go @@ -0,0 +1,160 @@ +// Package socket dials and accepts point-to-point sessions over hidden broadcasts. +package socket + +import ( + "context" + moq "moq.dev/moq" + ffi "moq.dev/moq-ffi/moq" + "moq.dev/moq/internal/bridge" +) + +// Endpoint announces one hidden endpoint and accepts TCP and track dials separately. +type Endpoint struct{ inner *ffi.MoqSocketEndpoint } + +// NewEndpoint announces id beneath prefix, using origin to find peers. +func NewEndpoint(origin *moq.OriginProducer, prefix, id string) (*Endpoint, error) { + inner, err := ffi.NewMoqSocketEndpoint(bridge.OriginProducer(origin), prefix, id) + if err != nil { + return nil, err + } + return &Endpoint{inner: inner}, nil +} + +// ID returns this endpoint's id. +func (e *Endpoint) ID() string { return e.inner.Id() } + +// Prefix returns the shared endpoint prefix. +func (e *Endpoint) Prefix() string { return e.inner.Prefix() } + +// Close retracts the endpoint and resets pending dials and sessions. +func (e *Endpoint) Close() { e.inner.Close() } + +// DialTCP dials a new tcp session; cancelling resets only this dial. +func (e *Endpoint) DialTCP(ctx context.Context, peer string) (*TCPStream, error) { + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTcpStream, error) { return e.inner.DialTcp(ctx, peer) }) + if err != nil { + return nil, err + } + return newTCPStream(inner), nil +} + +// AcceptTCP starts listening for tcp and awaits the next dial. +func (e *Endpoint) AcceptTCP(ctx context.Context) (*IncomingTCP, error) { + inner, err := bridge.CallHandle(ctx, nil, e.inner.AcceptTcp) + if err != nil { + return nil, err + } + return &IncomingTCP{inner: inner}, nil +} + +// IncomingTCP is a tcp dial, consumed by accepting or rejecting it. +type IncomingTCP struct{ inner *ffi.MoqSocketIncomingTcp } + +// Peer returns the claimed endpoint; accepting verifies return routability. +func (i *IncomingTCP) Peer() string { return i.inner.Peer() } + +// Session returns the private session id. +func (i *IncomingTCP) Session() string { return i.inner.Session() } + +// Reject refuses the dial as not found. +func (i *IncomingTCP) Reject() error { return i.inner.Reject() } + +// Accept verifies return routability and accepts this dial. +func (i *IncomingTCP) Accept(ctx context.Context) (*TCPStream, error) { + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTcpStream, error) { return i.inner.Accept(ctx) }) + if err != nil { + return nil, err + } + return newTCPStream(inner), nil +} + +// TCPStream exposes a tcp session's two halves and identities. +type TCPStream struct { + // Producer is this side's write half. + Producer *moq.GroupProducer + // Consumer is the peer's subscribed read half. + Consumer *moq.GroupConsumer + // Peer is the peer endpoint's id. + Peer string + // Session is the private session id. + Session string +} + +func newTCPStream(inner *ffi.MoqSocketTcpStream) *TCPStream { + defer inner.Destroy() + return &TCPStream{ + Producer: bridge.WrapGroupProducer(inner.Producer()).(*moq.GroupProducer), + Consumer: bridge.WrapGroupConsumer(inner.Consumer()).(*moq.GroupConsumer), + Peer: inner.Peer(), Session: inner.Session(), + } +} + +// DialTrack dials a new track session; cancelling resets only this dial. +func (e *Endpoint) DialTrack(ctx context.Context, peer string, info *moq.TrackInfo) (*TrackStream, error) { + ffiInfo, err := bridge.TrackInfo(info) + if err != nil { + return nil, err + } + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTrackStream, error) { + return e.inner.DialTrack(ctx, peer, ffiInfo) + }) + if err != nil { + return nil, err + } + return newTrackStream(inner), nil +} + +// AcceptTrack starts listening for track and awaits the next dial. +func (e *Endpoint) AcceptTrack(ctx context.Context) (*IncomingTrack, error) { + inner, err := bridge.CallHandle(ctx, nil, e.inner.AcceptTrack) + if err != nil { + return nil, err + } + return &IncomingTrack{inner: inner}, nil +} + +// IncomingTrack is a track dial, consumed by accepting or rejecting it. +type IncomingTrack struct{ inner *ffi.MoqSocketIncomingTrack } + +// Peer returns the claimed endpoint; accepting verifies return routability. +func (i *IncomingTrack) Peer() string { return i.inner.Peer() } + +// Session returns the private session id. +func (i *IncomingTrack) Session() string { return i.inner.Session() } + +// Reject refuses the dial as not found. +func (i *IncomingTrack) Reject() error { return i.inner.Reject() } + +// Accept verifies return routability and accepts this dial. +func (i *IncomingTrack) Accept(ctx context.Context, info *moq.TrackInfo) (*TrackStream, error) { + ffiInfo, err := bridge.TrackInfo(info) + if err != nil { + return nil, err + } + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqSocketTrackStream, error) { return i.inner.Accept(ctx, ffiInfo) }) + if err != nil { + return nil, err + } + return newTrackStream(inner), nil +} + +// TrackStream exposes a track session's two halves and identities. +type TrackStream struct { + // Producer is this side's write half. + Producer *moq.TrackProducer + // Consumer is the peer's subscribed read half. + Consumer *moq.TrackConsumer + // Peer is the peer endpoint's id. + Peer string + // Session is the private session id. + Session string +} + +func newTrackStream(inner *ffi.MoqSocketTrackStream) *TrackStream { + defer inner.Destroy() + return &TrackStream{ + Producer: bridge.WrapTrackProducer(inner.Producer()).(*moq.TrackProducer), + Consumer: bridge.WrapTrackConsumer(inner.Consumer()).(*moq.TrackConsumer), + Peer: inner.Peer(), Session: inner.Session(), + } +} diff --git a/go/wrapper/types.go b/go/wrapper/types.go index 5f1b7e08c5f..787a68d6edb 100644 --- a/go/wrapper/types.go +++ b/go/wrapper/types.go @@ -33,16 +33,18 @@ type ( ProtocolKind = ffi.MoqProtocolKind // OriginConfig configures a new origin, such as its maximum cache size in bytes. OriginConfig = ffi.MoqOriginConfig - // AnnounceRoute is how a broadcast advertises its exact path: its static production Cost (lower wins) and, optionally, a stable Instance. - // A nil Instance keeps the one the broadcast minted, which it keeps across re-announcing, so a restarted - // publisher is a new stream viewers restart onto. Set one only for a publisher that continues its group - // sequences across restarts and raises Epoch on each: readers stitch every route of one instance together, - // so restarting the sequences under an old instance splices unrelated groups into their tracks. - AnnounceRoute = ffi.MoqAnnounceRoute - // Route is an observed route: the Instance of the stream it serves (routes with one instance are one continuous stream), the hop chain it took to reach an origin, and its static production and link cost (lower wins). - Route = ffi.MoqRoute + // RouteOptions is how a [Route] advertises itself: Hidden until [Route.Announce], its static + // production Cost (lower wins) and, optionally, a stable Instance. The zero value is visible at + // once, at cost 0, under a fresh instance the route keeps for its lifetime, so a restarted + // publisher is a new stream viewers restart onto. Set Instance only for a publisher that + // continues its group sequences across restarts and raises Epoch on each: readers stitch every + // route of one instance together, so restarting the sequences under an old instance splices + // unrelated groups into their tracks. + RouteOptions = ffi.MoqRouteOptions + // RouteInfo is the read-only facts on a received route: the Instance of the stream it serves (routes with one instance are one continuous stream), the hop chain it took to reach an origin, and its static production and link cost (lower wins). + RouteInfo = ffi.MoqRouteInfo // Announce is a route over a prefix: the origin-relative Prefix, what each filter - // wildcard matched (Captures), and the Route serving it. It carries no broadcast; + // wildcard matched (Captures), and the RouteInfo serving it. It carries no broadcast; // resolve a path with [OriginConsumer.Request]. Captures is a required slice, // empty when the filter has no wildcards. Announce = ffi.MoqAnnounce diff --git a/infra/README.md b/infra/README.md index ff4d6a36191..b5c9e6ea4eb 100644 --- a/infra/README.md +++ b/infra/README.md @@ -42,7 +42,7 @@ which can update an existing worker but not create one or change its route. So the first deploy, and any change to the custom domain, runs by hand under a maintainer's `wrangler login`. Whether Editor alone passes wrangler's custom-domain check on every deploy is unproven until the first `release` -run; [the deploy quest](../quest/m2/release/moq-sh-deploy.md) tracks it. +run; [the deploy quest](../quest/m3/release/moq-sh-deploy.md) tracks it. ## Write credentials @@ -270,7 +270,14 @@ expires. `nix-ci-moq-dev` holds the dev shells `cache.yml` pushes after every merge to `main`, for x86 Linux, ARM Linux, and ARM macOS. It publishes the full build closure from `nix develop --profile`, uploading only missing paths, plus `moq-libav`, its wasm build, and its downloaded sources, which `bun install` -builds outside the dev shell. +builds outside the dev shell. An independent x86 Linux job builds and pushes +the `moq-relay` and `moq` runtime closures for Smoke's nix channel, which +reads them without write keys. Their existing Crane `cargoArtifacts` outputs +are published first so later builds can substitute them when their inputs +are unchanged; those inputs include the patched `kio` sources. The other +platforms have no Smoke nix lane. Runtime warms serialize: the active run +finishes and new pushes replace only the pending run, avoiding duplicate +builds without starving publication or delaying the dev-shell matrix. Its lifecycle rule (`infra/nix/lifecycle.json`) deletes each object 30 days after upload; the next warm re-uploads what it still needs. `flake.nix` sets `fallback = true` because that expiry can remove a path before one that diff --git a/js/common/release.test.ts b/js/common/release.test.ts index dc16e497e76..c8aba9e997d 100644 --- a/js/common/release.test.ts +++ b/js/common/release.test.ts @@ -11,7 +11,7 @@ type Manifest = { }; type Reply = { status?: number; output?: string; error?: string }; -// Run the real release entrypoint with isolated command substitutes: no registry +// Run the real release entrypoint with isolated command and fetch substitutes: no registry // credentials, builds, or publishing escape the fixture. function run(options: { manifest: Manifest; @@ -21,6 +21,7 @@ function run(options: { dryRun?: boolean; jsr?: boolean; published?: boolean; + jsrPublished?: boolean; jsrToken?: string; sibling?: { name: string; version: string; private?: boolean; scripts?: { release: string } }; }) { @@ -55,6 +56,18 @@ function run(options: { writeFileSync(join(packageDir, "package.json"), JSON.stringify(manifest)); } writeFileSync(join(cwd, "fixture.json"), JSON.stringify(options)); + const preload = join(cwd, "fetch.ts"); + writeFileSync( + preload, + `import { appendFileSync, readFileSync } from "node:fs"; +const options = JSON.parse(readFileSync("${cwd}/fixture.json", "utf8")); +globalThis.fetch = async (url) => { + appendFileSync("${cwd}/calls.jsonl", JSON.stringify(["fetch", String(url)]) + "\\n"); + if (String(url) !== "https://jsr.io/@moq/fixture/meta.json") process.exit(1); + return Response.json({ versions: options.jsrPublished ? { "1.0.0": {} } : {} }); +}; +`, + ); for (const tool of ["bun", "npm", "deno"]) { writeFileSync( join(bin, tool), @@ -85,17 +98,20 @@ if ("${tool}" === "bun") { { mode: 0o755 }, ); } - const result = Bun.spawnSync([process.execPath, release, ...(options.dryRun ? ["--dry-run"] : [])], { - cwd: packageDir, - env: { - PATH: bin, - HOME: cwd, - DRY_RUN: String(options.dryRun ?? false), - ...(options.jsrToken ? { JSR_TOKEN: options.jsrToken } : {}), + const result = Bun.spawnSync( + [process.execPath, "--preload", preload, release, ...(options.dryRun ? ["--dry-run"] : [])], + { + cwd: packageDir, + env: { + PATH: bin, + HOME: cwd, + DRY_RUN: String(options.dryRun ?? false), + ...(options.jsrToken ? { JSR_TOKEN: options.jsrToken } : {}), + }, + stdout: "pipe", + stderr: "pipe", }, - stdout: "pipe", - stderr: "pipe", - }); + ); const calls = readFileSync(join(cwd, "calls.jsonl"), "utf8") .trim() .split("\n") @@ -204,6 +220,7 @@ test("JSR dry-run checks its import map once and runs when dependencies are publ test("the JSR token reaches only deno publish", () => { const result = run({ jsr: true, jsrToken: "jsr-secret", manifest: {} }); expect(result.code).toBe(0); + expect(result.calls).toContainEqual(["fetch", "https://jsr.io/@moq/fixture/meta.json"]); expect(result.calls.at(-1)).toEqual([ "deno", "publish", @@ -218,6 +235,15 @@ test("the JSR token reaches only deno publish", () => { expect(result.leaks).toEqual([]); }); +test("an already-published version on both registries skips publishing", () => { + const result = run({ jsr: true, published: true, jsrPublished: true, manifest: {} }); + expect(result.code).toBe(0); + expect(result.calls).toEqual([ + ["npm", "view", "@moq/fixture", "version"], + ["fetch", "https://jsr.io/@moq/fixture/meta.json"], + ]); +}); + test("a local sibling cannot satisfy the registry check for a real publish", () => { const result = run({ sibling: { name: "@moq/net", version: "999.0.0" }, diff --git a/js/crypto/README.md b/js/crypto/README.md index 29316633daf..09b85db380c 100644 --- a/js/crypto/README.md +++ b/js/crypto/README.md @@ -36,10 +36,11 @@ const { Broadcast, SecretKey } = Sign; const key = await SecretKey.generate(); const path = Path.from(`mesh/${key.public}/agent`); -const publication = origin.createBroadcast(path); -const signed = new Broadcast.Producer(publication.broadcast, key, path); +const route = origin.route(path, { hidden: true }); +// Signs under the route's instance, which readers verify against. +const signed = new Broadcast.Producer(route.broadcast, key, { path, instance: route.instance }); const messages = signed.createTrack("messages"); -publication.announce({ instance: signed.instance }); +route.announce(); const group = messages.appendGroup(); await group.writeFrame(undefined, payload); @@ -87,15 +88,19 @@ every write is checked against it. `close()` ends the wrapped broadcast. import { E2ee } from "@moq/crypto"; import { Time } from "@moq/net"; -const { Broadcast, Credential } = E2ee; +const { Broadcast, Credential, Epoch } = E2ee; const credential = new Credential({ context, kid, secret }); const generation = credential.produce(); -const publication = origin.createBroadcast(await credential.path("meeting.hang")); -const video = await new Broadcast.Producer(publication.broadcast, generation).createTrack("video", { +// The route's instance names the generation's epoch, so subscribers find the key. +const route = origin.route(await credential.path("meeting.hang"), { + hidden: true, + instance: Epoch.instance(generation.epoch), +}); +const video = await new Broadcast.Producer(route.broadcast, generation).createTrack("video", { timescale: Time.Timescale.MILLI, }); -publication.announce({ epoch: generation.epoch }); +route.announce(); const group = video.appendGroup(); await group.writeFrame(Time.Timestamp.now(), frame); @@ -105,8 +110,8 @@ video.close(); ``` A subscriber follows `credential.path(semantic)` with `origin.follow(path)`. It takes each -instance's epoch from the broadcast it resolved, `broadcast.epoch` (or from the application's -channel on a wire without route epochs), so the key always matches the instance it reads. It +instance's epoch from the broadcast it resolved, `Epoch.of(broadcast)` (or from the application's +channel on a wire without route instances), so the key always matches the instance it reads. It binds the epoch with `credential.consume(epoch)` and wraps the broadcast in a `Broadcast.Consumer`. Its `subscribe(semantic)` opens the track a decrypted catalog names, and `close()` releases the diff --git a/js/crypto/src/e2ee/index.ts b/js/crypto/src/e2ee/index.ts index 949c58e758e..c7db9f1270a 100644 --- a/js/crypto/src/e2ee/index.ts +++ b/js/crypto/src/e2ee/index.ts @@ -7,8 +7,8 @@ * * A {@link Credential} is what the application distributes out of band. Each publisher instance * calls {@link Credential.produce} to mint a {@link Generation.Producer} and wraps its `@moq/net` - * broadcast in a {@link Broadcast.Producer}, announcing the generation's epoch as its route's - * instance. Subscribers follow the path, read each instance's epoch with {@link Epoch.of} from + * broadcast in a {@link Broadcast.Producer}, creating its route under the generation's epoch as + * its instance. Subscribers follow the path, read each instance's epoch with {@link Epoch.of} from * the broadcast it resolved, bind a {@link Generation.Consumer} with * {@link Credential.consume}, and wrap the broadcast in a {@link Broadcast.Consumer}. Both sides name tracks by their semantic names; only the * derived physical names reach `@moq/net`. @@ -16,9 +16,12 @@ * ```ts * const credential = new Credential({ context, kid, secret }); * const generation = credential.produce(); - * const publication = origin.createBroadcast(await credential.path("meeting.hang")); - * const video = await new Broadcast.Producer(publication.broadcast, generation).createTrack("video", { timescale }); - * publication.announce({ instance: Epoch.instance(generation.epoch) }); + * const route = origin.route(await credential.path("meeting.hang"), { + * hidden: true, + * instance: Epoch.instance(generation.epoch), + * }); + * const video = await new Broadcast.Producer(route.broadcast, generation).createTrack("video", { timescale }); + * route.announce(); * * const group = video.appendGroup(); * await group.writeFrame(timestamp, frame); diff --git a/js/crypto/src/e2ee/transport.test.ts b/js/crypto/src/e2ee/transport.test.ts index 176690027de..712f105a042 100644 --- a/js/crypto/src/e2ee/transport.test.ts +++ b/js/crypto/src/e2ee/transport.test.ts @@ -62,12 +62,12 @@ for (const [name, protocol, routed, timed] of transports) { const cred = credential(); const generation = cred.produce(); const path = await cred.path("meeting.hang"); - const broadcastPublication = publisher.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = publisher.route(path, { hidden: true, instance: Epoch.instance(generation.epoch) }); + const broadcast = broadcastRoute.broadcast; const video = await new Protected.Producer(broadcast, generation).createTrack("video", { timescale: Time.Timescale.MILLI, }); - broadcastPublication.announce({ instance: Epoch.instance(generation.epoch) }); + broadcastRoute.announce(); const group = video.appendGroup(); await group.writeFrame(ms(1), encoder.encode("keyframe")); @@ -119,12 +119,12 @@ for (const [name, protocol] of [ const cred = credential(); const generation = cred.produce(); const path = await cred.path("meeting.hang"); - const broadcastPublication = publisher.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = publisher.route(path, { hidden: true, instance: Epoch.instance(generation.epoch) }); + const broadcast = broadcastRoute.broadcast; const audio = await new Protected.Producer(broadcast, generation).createDatagrams("audio", { timescale: Time.Timescale.MILLI, }); - broadcastPublication.announce({ instance: Epoch.instance(generation.epoch) }); + broadcastRoute.announce(); const remote = await active(subscriber, path); const consumer = await new Protected.Consumer( diff --git a/js/crypto/src/sign/broadcast.ts b/js/crypto/src/sign/broadcast.ts index fecfde5f21d..5d7ffebb90a 100644 --- a/js/crypto/src/sign/broadcast.ts +++ b/js/crypto/src/sign/broadcast.ts @@ -15,23 +15,32 @@ import * as Track from "./track.ts"; /** * The publishing side of a signed broadcast. * - * Mints a fresh instance, so a restart never signs a sequence an earlier instance already signed. - * Announce the wrapped broadcast with {@link instance} on its route. + * Signs under the instance of the route that serves it, which a route mints fresh, so a restart + * never signs a sequence an earlier instance already signed. */ export class Producer { - /** The instance this producer signs under, to announce on the broadcast's route. */ - readonly instance: Instance.Valid = Instance.random(); + /** The instance this producer signs under: the route's that serves the broadcast. */ + readonly instance: Instance.Valid; #inner: NetBroadcast.Producer; #key: SecretKey; #path: Path.Valid; // Each track's lowest unsigned sequence, kept across the track's replacements. #sequences = new Map(); - /** Sign `inner` with `key`, as the broadcast at application path `path`, taking ownership of it: {@link close} ends it. */ - constructor(inner: NetBroadcast.Producer, key: SecretKey, path: Path.Valid) { + /** + * Sign `inner` with `key`, as the broadcast at application path `path` served under `instance`, + * taking ownership of it: {@link close} ends it. Pass the instance of the route that serves it, + * such as `route.instance` for the route that owns `inner`. + */ + constructor( + inner: NetBroadcast.Producer, + key: SecretKey, + { path, instance }: { path: Path.Valid; instance: Instance.Valid }, + ) { this.#inner = inner; this.#key = key; this.#path = path; + this.instance = instance; } /** Settles with `null` once the wrapped broadcast closes, as `@moq/net`'s `closed` does. */ diff --git a/js/crypto/src/sign/index.ts b/js/crypto/src/sign/index.ts index b840f107158..8b9e5b4704d 100644 --- a/js/crypto/src/sign/index.ts +++ b/js/crypto/src/sign/index.ts @@ -13,10 +13,10 @@ * ```ts * const key = await SecretKey.generate(); * const path = Path.from(`mesh/${key.public}/agent`); - * const publication = origin.createBroadcast(path); - * const signed = new Broadcast.Producer(publication.broadcast, key, path); + * const route = origin.route(path, { hidden: true }); + * const signed = new Broadcast.Producer(route.broadcast, key, { path, instance: route.instance }); * const messages = signed.createTrack("messages"); - * publication.announce({ instance: signed.instance }); + * route.announce(); * * const group = messages.appendGroup(); * await group.writeFrame(undefined, payload); diff --git a/js/crypto/src/sign/sign.test.ts b/js/crypto/src/sign/sign.test.ts index d2f1d01da46..342bb759c24 100644 --- a/js/crypto/src/sign/sign.test.ts +++ b/js/crypto/src/sign/sign.test.ts @@ -46,15 +46,14 @@ async function active(origin: Origin.Producer, path: Path.Valid): Promise>]>>; -export type Contribution = infer; - -export const ContributionSchema: ZodMiniObject<{ - broadcast: ZodMiniType>; - instance: ZodMiniOptional>>; -}, $strip>; - export const DEFAULT_FORMAT: Format; export class DuplicateRendition extends Error { @@ -204,6 +197,13 @@ export type Format = typeof FORMATS[number]; export type Hex = infer; +export type Include = infer; + +export const IncludeSchema: ZodMiniObject<{ + broadcast: ZodMiniType>; + instance: ZodMiniOptional>>; +}, $strip>; + export const InstanceSchema: ZodMiniType; export type Join = infer; @@ -330,13 +330,13 @@ export const RootSchema: ZodMiniObject<{ track?: string | undefined; }>, Record>>; }, $strip>, ZodMiniPipe, ZodMiniTransform>]>>; - contributions: ZodMiniOptional, Record>, ZodMiniTransform, Record>>>; echo: ZodMiniOptional>; }, $strip>, ZodMiniPipe, ZodMiniTransform>]>>; + include: ZodMiniOptional, Record>, ZodMiniTransform, Record>>>; json: ZodMiniOptional, Record>, ZodMiniTransform - ({ contributions: { entry: { broadcast: Moq.Path.normalizeRelative(broadcast) } } }) as Root; - expect(() => checkResolvable(contribution("../../x"), base)).toThrow(EscapingBroadcast); - expect(checkResolvable(contribution("../x"), base)).toBeDefined(); + // An include is a whole broadcast, keyed directly under its section. + const include = (broadcast: string) => + ({ include: { entry: { broadcast: Moq.Path.normalizeRelative(broadcast) } } }) as Root; + expect(() => checkResolvable(include("../../x"), base)).toThrow(EscapingBroadcast); + expect(checkResolvable(include("../x"), base)).toBeDefined(); }); test("watch yields a catalog with many renditions", async () => { @@ -87,11 +87,11 @@ function referencing(broadcast: string): Root { // Watch the catalog of `room/alice`, published through an origin and requested from it. async function watchRequested(root: Root) { const origin = new Moq.Origin.Producer(); - const broadcastPublication = origin.createBroadcast(Moq.Path.from("room/alice")); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = origin.route(Moq.Path.from("room/alice"), { hidden: true }); + const broadcast = broadcastRoute.broadcast; const track = broadcast.createTrack(TRACK, { timescale: Moq.Time.Timescale.MILLI }); const producer = new Json.Snapshot.Producer({ track, deltas: false }); - broadcastPublication.announce(); + broadcastRoute.announce(); const request = origin.request(Moq.Path.from("room/alice")); const active = request.active.peek(); if (!active) throw new Error("request did not resolve"); diff --git a/js/hang/src/catalog/contribution.ts b/js/hang/src/catalog/include.ts similarity index 58% rename from js/hang/src/catalog/contribution.ts rename to js/hang/src/catalog/include.ts index 68368a85793..b59bf112e97 100644 --- a/js/hang/src/catalog/contribution.ts +++ b/js/hang/src/catalog/include.ts @@ -4,18 +4,21 @@ import { InstanceSchema, RelativeBroadcastSchema } from "./path"; /** * A whole broadcast whose own catalog describes its renditions, listed without opening it. * - * Listing one subscribes to nothing: a consumer opens the contribution's catalog only when it + * It shares the listing catalog's PTS timeline and root settings, including `media.clock` and + * `media.join.interval`; its own catalog's settings are ignored. + * + * Listing one subscribes to nothing: a consumer opens the include's catalog only when it * selects it, including an HLS master listing its renditions. An on-demand publisher answers * the catalog without starting media. Its rendition IDs belong to that broadcast, never the * listing catalog. */ -export const ContributionSchema = z.object({ - // The contribution's broadcast, relative to the broadcast serving this catalog. +export const IncludeSchema = z.object({ + // The include's broadcast, relative to the broadcast serving this catalog. broadcast: RelativeBroadcastSchema, // The stream the reference names, with a rendition `instance`'s meaning. A composer leaves it - // out until the contribution resolves. + // out until the include resolves. instance: z.optional(InstanceSchema), }); /** A whole broadcast listed for explicit, lazy selection. */ -export type Contribution = z.infer; +export type Include = z.infer; diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts index e99954e9dbf..6ca97df89eb 100644 --- a/js/hang/src/catalog/index.ts +++ b/js/hang/src/catalog/index.ts @@ -2,8 +2,8 @@ * JSON catalog schema and types describing a broadcast's tracks: audio/video decoder configs for * WebCodecs, container format, rendition layout, the `media` section (the broadcast's `clock` and * `join` interval), - * plus the `json` and `binary` data sections, optional `stats` and `echo` discovery, and whole - * `contributions` listed without opening them. + * plus the `json` and `binary` data sections, optional `stats` and `echo` discovery, and + * broadcasts listed in `include` without opening them. * * @module */ @@ -25,10 +25,10 @@ export { AudioConfigSchema, AudioSchema } from "./audio"; export * from "./binary"; export * from "./clock"; export * from "./container"; -export * from "./contribution"; export * from "./echo"; export * from "./format"; export * from "./hex"; +export * from "./include"; export * from "./integers"; export * from "./json"; export * from "./media"; diff --git a/js/hang/src/catalog/parity.test.ts b/js/hang/src/catalog/parity.test.ts index 9cb15ec7bfc..a33d90219a7 100644 --- a/js/hang/src/catalog/parity.test.ts +++ b/js/hang/src/catalog/parity.test.ts @@ -10,11 +10,11 @@ interface Case { text?: string[]; json?: string[]; binary?: string[]; - contributions?: string[]; + include?: string[]; } // The corpus rs/hang also loads: both languages accept the same catalogs and keep the same -// rendition, data track, and contribution IDs, so a divergence fails CI on whichever side drifts. +// rendition, data track, and include IDs, so a divergence fails CI on whichever side drifts. const cases = (await Bun.file( new URL("../../../../rs/hang/fixtures/catalog-parity.json", import.meta.url), ).json()) as Case[]; @@ -35,6 +35,6 @@ for (const c of cases) { expect(ids(root.text?.renditions)).toEqual(c.text ?? []); expect(ids(root.json?.tracks)).toEqual(c.json ?? []); expect(ids(root.binary?.tracks)).toEqual(c.binary ?? []); - expect(ids(root.contributions)).toEqual(c.contributions ?? []); + expect(ids(root.include)).toEqual(c.include ?? []); }); } diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index 1e8123f7cee..1abfc205ad0 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -54,7 +54,7 @@ test("rendition broadcast reference is parsed and normalized", () => { test("every reference-bearing section carries an optional instance", () => { const INSTANCE = "01890a5d-ac96-774b-bcce-b302099a8057" as ReferenceInstance; const catalog = (instance: string) => ({ - contributions: { c: { broadcast: "./a", instance } }, + include: { c: { broadcast: "./a", instance } }, video: { renditions: { v: { broadcast: "./a", instance, codec: "avc1.64001f", container: { kind: "legacy" } }, @@ -85,36 +85,36 @@ test("every reference-bearing section carries an optional instance", () => { expect(parsed.text?.renditions.t?.instance).toBe(INSTANCE); expect(parsed.json?.tracks.j?.instance).toBe(INSTANCE); expect(parsed.binary?.tracks.b?.instance).toBe(INSTANCE); - expect(parsed.contributions?.c?.instance).toBe(INSTANCE); + expect(parsed.include?.c?.instance).toBe(INSTANCE); // An empty instance refuses the catalog, as in Rust. expect(RootSchema.safeParse(catalog("")).success).toBe(false); }); -// Mirrors Rust's `contributions_roundtrip_and_refuse_malformed_entries`. -test("a contribution parses beside a same-ID rendition and a malformed one is refused", () => { +// Mirrors Rust's `includes_roundtrip_and_refuse_malformed_entries`. +test("an include parses beside a same-ID rendition and a malformed one is refused", () => { const video = { alice: { codec: "avc1.64001f", container: { kind: "legacy" } } }; const parsed = RootSchema.parse({ - contributions: { alice: { broadcast: "./alice.hang" } }, + include: { alice: { broadcast: "./alice.hang" } }, video: { renditions: video }, }); - expect(parsed.contributions?.alice).toEqual({ broadcast: "alice.hang" as RelativeBroadcast }); - // Contribution IDs are their own namespace, so one may share a rendition's ID. + expect(parsed.include?.alice).toEqual({ broadcast: "alice.hang" as RelativeBroadcast }); + // Include IDs are their own namespace, so one may share a rendition's ID. expect(() => checkRenditions(parsed)).not.toThrow(); expect(encode(parsed).version).toBeUndefined(); - for (const contributions of [ + for (const include of [ [], { alice: null }, { alice: {} }, { alice: { broadcast: 42 } }, { alice: { broadcast: "./alice.hang", instance: "" } }, ]) { - expect(RootSchema.safeParse({ contributions }).success).toBe(false); + expect(RootSchema.safeParse({ include }).success).toBe(false); } - // The issue names a contribution, not a rendition. - const refused = RootSchema.safeParse({ contributions: { alice: {} } }); - expect(refused.error?.message).toContain("malformed contribution alice"); + // The issue names an include, not a rendition. + const refused = RootSchema.safeParse({ include: { alice: {} } }); + expect(refused.error?.message).toContain("malformed include alice"); }); test("rendition parent broadcast reference stays distinct from empty", () => { diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts index b5c4a9fe29a..80f80eb11d4 100644 --- a/js/hang/src/catalog/root.ts +++ b/js/hang/src/catalog/root.ts @@ -4,9 +4,9 @@ import { Path } from "@moq/net"; import * as z from "zod/mini"; import { AudioSchema } from "./audio"; import { BinarySchema } from "./binary"; -import { ContributionSchema } from "./contribution"; import { EchoSchema } from "./echo"; import { TRACK } from "./format"; +import { IncludeSchema } from "./include"; import { JsonSchema } from "./json"; import { MediaSchema } from "./media"; import type { RelativeBroadcast } from "./path"; @@ -50,8 +50,8 @@ const VersionSchema = z.number().check( * to every audio and video track: the broadcast's `clock` (the one wall-clock mapping) and its * `join` interval. `json` and `binary` list application data tracks that aren't media. A section is omitted * when it holds no tracks. `stats` names the publisher's media snapshot track, `echo` - * solicits viewer feedback under a relative broadcast prefix, and `contributions` lists whole - * broadcasts without opening them. + * solicits viewer feedback under a relative broadcast prefix, and `include` lists whole + * broadcasts without opening them; each shares this catalog's PTS timeline and root settings. * * This is a *loose* object: unknown root sections pass through validation untouched, so an * application can add its own sections (e.g. `scte35`) without modifying hang. A base consumer @@ -85,7 +85,7 @@ export const RootSchema = z.looseObject({ json: section(JsonSchema, "tracks"), binary: section(BinarySchema, "tracks"), // A new key, so a malformed entry fails the catalog, the same as Rust. - contributions: z.optional(renditions(ContributionSchema, "contribution")), + include: z.optional(renditions(IncludeSchema, "include")), }); /** The root catalog object: the media sections, `media`, the data track sections, plus any app extensions. */ @@ -172,7 +172,7 @@ export function checkResolvable(root: Root, base: Moq.Path.Valid): Root { root.text?.renditions, root.json?.tracks, root.binary?.tracks, - root.contributions, + root.include, ]; for (const section of sections) { for (const { broadcast } of Object.values(section ?? {})) { diff --git a/js/hang/src/catalog/section.ts b/js/hang/src/catalog/section.ts index a6f0c1a15c8..b739c2944ad 100644 --- a/js/hang/src/catalog/section.ts +++ b/js/hang/src/catalog/section.ts @@ -35,7 +35,7 @@ export function section(schema: T, map: string) { } /** - * A catalog map keyed by ID (renditions, data tracks, contributions), refusing the catalog if any + * A catalog map keyed by ID (renditions, data tracks, includes), refusing the catalog if any * entry is malformed. * * Each entry must parse with `schema`; the issue names the entry. An unknown container @@ -43,7 +43,7 @@ export function section(schema: T, map: string) { * `deserialize_renditions`. * * @param schema - one entry's schema. - * @param kind - what an entry is, naming it in the issue (`rendition`, `contribution`). + * @param kind - what an entry is, naming it in the issue (`rendition`, `include`). */ export function renditions(schema: T, kind = "rendition") { return z.pipe( diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index adbb69f3bbc..a04b81d3aad 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -178,8 +178,7 @@ export const VideoConfigSchema = z.object({ bitrate: z.optional(u53Schema), // Whether this rendition may be selected. When false, no frames are coming and a consumer - // should not select it, though one may keep it selected while no enabled alternative exists. - // Default: true, so publishers only write it when false. + // must not select it. Default: true, so publishers only write it when false. enabled: z.optional(z.boolean()), // Higher is preferred: a player selects among the highest preference it can decode, then by diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 159acea7fb6..d5090a4762a 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -856,6 +856,51 @@ test("Consumer measures arrivals as they land", async () => { } }); +test("Consumer does not relax its measurement across a declared silence", async () => { + const clock = new Manual(); + const installed = clock.install(); + // Two identical paths, except that one publisher declares its silence and the other just stops. + const declared = new Track.Producer("declared"); + const undeclared = new Track.Producer("undeclared"); + const options = { format: new LegacyFormat("audio"), delay: { max: 5000 as Milli } }; + const quiet = new Consumer(declared.subscribe(), options); + const lapsed = new Consumer(undeclared.subscribe(), options); + const producers = [new LegacyProducer(declared, options.format), new LegacyProducer(undeclared, options.format)]; + const payload = new Uint8Array([1]); + let media = 0; + const send = async (count: number) => { + for (let i = 0; i < count; i++) { + for (const producer of producers) producer.encode(payload, (media * 1000) as Micro, true); + media += 520; + await clock.advance(Nano.fromMilli(Milli(520))); + } + }; + + try { + // Arrivals exactly as fast as their media until the cold-start prior is spent. + await send(40); + const settled = quiet.spread.peek(); + expect(lapsed.spread.peek()).toBe(settled); + + // A minute of silence on both, declared on one. + producers[0].discontinuity(); + media += 60_000; + await clock.advance(Nano.fromMilli(Milli(60_000))); + await send(2); + + expect(quiet.spread.peek()).toBe(settled); + expect(lapsed.spread.peek()).toBeGreaterThan(settled); + } finally { + for (const track of [declared, undeclared]) { + track.finish(); + track.close(); + } + quiet.close(); + lapsed.close(); + installed.close(); + } +}); + // --- Ordering --- test("Consumer delivers groups in sequence order regardless of arrival order", async () => { diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index 50c8e5afbca..9ee90a2fef3 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -221,6 +221,11 @@ export class Consumer { * floor. Starts at the predecessor's estimate, or the cold-start estimate without one. */ readonly spread: Getter; + // The newest group opened by a declared end, until a later group's first frame tells the jitter + // estimator the silence is over. Not at the marker itself: the stopped encoder's final packets may + // follow it in its own group, and they belong to the speech before the silence. + #declared?: bigint; + #mediaSequence?: bigint; #signals = new Effect(); #closed = new Once(); @@ -378,6 +383,13 @@ export class Consumer { } } + // Note an end the publisher declared in `group`, which a later group's first frame resumes from. + #declare(group: Group): void { + const sequence = group.consumer.sequence; + if (this.#mediaSequence !== undefined && sequence < this.#mediaSequence) return; + if (this.#declared === undefined || sequence > this.#declared) this.#declared = sequence; + } + // Decode one wire frame into `group`, charging what it buffers. A payload that fails to decode sets // the group's fault, which next() raises once the frames ahead of it are read. #decode(group: Group, wire: Moq.Group.Frame): void { @@ -401,6 +413,7 @@ export class Consumer { return; } group.bare = decoded.length === 0; + if (group.bare) this.#declare(group); } // Decoded payloads are views of the wire payload, which stays allocated until the last of them is @@ -412,8 +425,17 @@ export class Consumer { // Observed here, before any gap handling or age budget: a target derived from // what survives the budget would only ever confirm the budget it was cut to. if (!marker) { + if (this.#mediaSequence === undefined || group.consumer.sequence > this.#mediaSequence) + this.#mediaSequence = group.consumer.sequence; + if (this.#declared !== undefined && group.consumer.sequence > this.#declared) { + this.#jitter.end(); + this.#declared = undefined; + } this.#jitter.observe(arrival, Milli.fromMicro(sample.timestamp)); this.#spread.set(this.#jitter.measured); + } else if (!group.media) { + // Only a marker ahead of any media declares an end; one after it is a frame's endpoint. + this.#declare(group); } const frame: ReceivedFrame = { arrival, diff --git a/js/hang/src/container/jitter.test.ts b/js/hang/src/container/jitter.test.ts index 4e2c8c32bcc..c346af8d378 100644 --- a/js/hang/src/container/jitter.test.ts +++ b/js/hang/src/container/jitter.test.ts @@ -16,6 +16,7 @@ interface Case { delay?: number; arrival: number[]; media: number[]; + ends?: number[]; target: [number, number][]; } @@ -41,6 +42,7 @@ describe("audio jitter conformance", () => { "idle", "paced", "reorder", + "silence", "spike", "straggler", "tunein", @@ -51,8 +53,10 @@ describe("audio jitter conformance", () => { it(c.name, () => { const jitter = new Jitter(); const expected = new Map(c.target); + const ends = new Set(c.ends); let current: number | undefined; for (let i = 0; i < c.arrival.length; i++) { + if (ends.has(i)) jitter.end(); jitter.observe(c.arrival[i] as Milli, c.media[i] as Milli); const value = target(jitter, c); const listed = expected.get(i); @@ -84,6 +88,17 @@ describe("Jitter", () => { expect(jitter.measured).toBe(20 as Milli); }); + it("ignores an end before any observation", () => { + // A cold estimator has no interval for a silence to stretch, so the end must not linger and + // excuse the first unexplained gap. + const jitter = new Jitter(); + jitter.end(); + for (let i = 0; i < 500; i++) jitter.observe((50 + i * 20) as Milli, (i * 20) as Milli); + const settled = jitter.measured; + jitter.observe((50 + 500 * 20 + 30_000) as Milli, (500 * 20 + 30_000) as Milli); + expect(jitter.measured).toBeGreaterThan(settled); + }); + it("does not follow the absolute delay", () => { // Only the spread of arrivals matters, not how far away the sender is. const near = new Jitter(); diff --git a/js/hang/src/container/jitter.ts b/js/hang/src/container/jitter.ts index 0759bf70b6d..257beda9c2d 100644 --- a/js/hang/src/container/jitter.ts +++ b/js/hang/src/container/jitter.ts @@ -61,6 +61,8 @@ export class Jitter { #interval = 0; /** The largest delay seen in that interval. */ #peak = 0; + /** A declared end precedes the next measured observation, so the silence before it is not idle. */ + #ended = false; #histogram = Float64Array.from(PRIOR); /** How much of the histogram is still the prior's rather than evidence. */ @@ -93,6 +95,8 @@ export class Jitter { } const interval = Math.floor((arrival - this.#origin) / RESAMPLE); + const resumed = this.#ended; + this.#ended = false; if (interval <= this.#interval) { this.#peak = Math.max(this.#peak, delay); return; @@ -100,14 +104,30 @@ export class Jitter { this.#fold(Math.min(BUCKETS - 1, Math.floor(this.#peak / BUCKET))); - // An interval with no arrival relaxes the estimate toward the prior at the same rate. - const idle = Math.min(interval - this.#interval - 1, IDLE_MAX); - for (let i = 0; i < idle; i++) this.#fold(undefined); - - this.#interval = interval; + if (resumed) { + // A silence the publisher declared says nothing about the path, so the estimate keeps + // what it had and a fresh interval starts here. + this.#origin = arrival; + this.#interval = 0; + } else { + // An interval with no arrival relaxes the estimate toward the prior at the same rate. + const idle = Math.min(interval - this.#interval - 1, IDLE_MAX); + for (let i = 0; i < idle; i++) this.#fold(undefined); + this.#interval = interval; + } this.#peak = delay; } + /** + * A declared end lies between the previous observation and the next, so the silence it declares + * does not relax the estimate the way an unexplained gap does. Call it before observing the first + * frame after the end, not at the marker: the stopped encoder's final packets may still follow it. + */ + end(): void { + // Before any observation there is no interval for the silence to stretch. + if (this.#origin !== undefined) this.#ended = true; + } + /** * A new estimator that keeps what this one learned but measures arrivals afresh, for a track that * replaces this one on the same path. Its frames are measured against their own fastest, never diff --git a/js/moq-boy/src/game.test.ts b/js/moq-boy/src/game.test.ts index 4070d14ef80..57f48e84873 100644 --- a/js/moq-boy/src/game.test.ts +++ b/js/moq-boy/src/game.test.ts @@ -58,8 +58,7 @@ test("status skips values that fail the schema and ends when the track errors", const connection = new Moq.Connection({ enabled: false }); const origin = new Moq.Origin.Producer(); - const publication = origin.createBroadcast(Moq.Path.from("game", "test")); - publication.announce(); + const publication = origin.route(Moq.Path.from("game", "test")); const track = publication.broadcast.createTrack("status"); // One finished group per status, so between statuses the loop waits on the next group. diff --git a/js/moq-boy/src/game.ts b/js/moq-boy/src/game.ts index bbfff3d1d06..7c8fa6a6fb3 100644 --- a/js/moq-boy/src/game.ts +++ b/js/moq-boy/src/game.ts @@ -323,13 +323,10 @@ export class Game { const viewerId = Math.random().toString(36).slice(2, 8); this.viewerId.set(viewerId); - const viewerBroadcastPublication = origin.createBroadcast( - Moq.Path.from(this.#viewerPrefix, this.sessionId, viewerId), - ); - const viewerBroadcast = viewerBroadcastPublication.broadcast; - viewerBroadcastPublication.announce(); + const viewerRoute = origin.route(Moq.Path.from(this.#viewerPrefix, this.sessionId, viewerId)); + const viewerBroadcast = viewerRoute.broadcast; effect.cleanup(() => { - viewerBroadcast.close(); + viewerRoute.close(); this.viewerId.set(undefined); }); diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index e88a594fd64..5d288408d44 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,46 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Track requests wait for their answer instead of timing out + +A subscription, `info()`, or `fetchGroup()` the publisher hasn't answered no longer fails +with `StreamCode.ControlTimeout` after ten seconds, since the publisher's application may +hold its answer, as Rust allows. It waits until the last subscriber closes, the fetch's +`signal` aborts, the broadcast or session closes, or the publisher refuses it. +Waiting for stream credit also lasts until cancellation. To give up sooner, close +the subscriber or abort the fetch on a timer of your own. `info({ signal })` accepts +the same cancellation signal. Announcement discovery keeps waiting while it is wanted; +IETF advertisements retain their answer budget. + +### Socket endpoints are available + +The additive `socket` namespace exports `Endpoint`, `TcpStream`, and `TrackStream`. +Use `dialTcp` / `acceptTcp` or `dialTrack` / `acceptTrack` on endpoints sharing a prefix; +cancel pending operations with an `AbortSignal`, and explicitly close endpoints when done. +Track readers skip groups that are refused, dropped, or evicted instead of waiting forever. +TCP readers join their one group live, recovering its head with a joining FETCH or fill +over moq-transport. The existing wire is unchanged. Every hop must support moq-lite-07 (currently an +explicit `moq-lite-07-wip` offer) or moq-transport; while the endpoint's own client +connection is moq-lite before 07, its dials and accepts fail as unsupported. + +### Absolute IETF subscriptions keep their prefix + +Explicit group ranges now keep their requested start when the subscription answer +arrives after its first data. IETF publishers honor absolute starts on all +supported drafts instead of silently joining at the live edge. A start of group 0 +is a filterless SUBSCRIBE on every draft, and the groups below the answer's Largest +Object come from one range FETCH. A `Subscribe` with an unfiltered filter now omits +LOCATION\_FILTER, and a SUBSCRIBE without one still joins live. There is no API +change. + +### WebSocket receive windows match Rust + +The fallback now grants 4 MiB per connection and 1 MiB per stream, up from +1 MiB and 256 KiB. Set `websocket.receiveWindow` and +`websocket.streamReceiveWindow` in connection options to tune these byte budgets +or retain the old limits. Both accept positive safe integers, like +`websocket.maxStreams`. `@moq/qmux`'s standalone defaults are unchanged. + ### Datagrams use separate track types ([#7846](https://github.com/moq-dev/moq/pull/7846)) Create a datagram track with `broadcast.createDatagrams(name, info)` and read it with @@ -32,9 +72,51 @@ currently refuses them while its separate datagram-track adapter is implemented. `Announce.Event` is `{ kind: "present" | "absent" } & Announce`. Another instance taking a path over keeps it present, and a route's cost or hops changing is not reported, so `start`, `update`, `restart` and `end` are gone. Map `"start"` to `"present"` and `"end"` to `"absent"`, and drop `"update"` branches. Where you requested the path again on `"restart"`, hold `origin.follow(path)`: its `active` moves to each new instance's broadcast. +### One `Origin.Route` handle replaces publications, claims, and prepared answers + +`Origin.Producer.createBroadcast`, `requests`, and `requestsRoute`, and the `Origin.Publication`, `Origin.Prepared`, and `Origin.Requests` classes are gone. +An `Origin.Route` owns what you publish: `origin.route(path, options?)` creates an exact route that owns a new broadcast, and `origin.routePrefix(prefix, options?)` creates a prefix route that yields requests. +A route is visible at once unless created with `{ hidden: true }`; `announce()` and `withdraw()` toggle it, `announced` says which, and `close()` retracts it and closes its broadcast, as closing the broadcast does. +Its `instance` is minted when the route is created and kept across `withdraw()` and `announce()`, so a reader continues rather than restarts. A new route is a new stream. +`RouteOptions` sets what `announce(route)` took: `cost`, `auth`, and a stable `instance` with its `epoch`. Re-price with `setCost(cost)` and swap the credential with `setAuth(auth)`. + +```ts +// Before +const publication = origin.createBroadcast(path); +publication.broadcast.createTrack("catalog.json"); +publication.announce({ cost: 5n }); +publication.unannounce(); + +// After +const route = origin.route(path, { hidden: true, cost: 5n }); +route.broadcast.createTrack("catalog.json"); +route.announce(); +route.withdraw(); // announce() again continues the same instance +``` + +Drop the separate `announce()` call when nothing needs filling first: `origin.route(path)` is already visible. +A prefix route replaces `requests(prefix)`: iterate `route.requested()` as before, and answer each request with `serve(broadcast)`, `reject(err)`, or `request.route(options?)`, which replaces `prepare()` and `Prepared.publish()`. +A job's route is visible at once and answers the waiting requesters unless created hidden; a hidden one answers them on `announce()`, and closing it or its broadcast before then refuses them as `Unroutable`. + +```ts +// Before +using prepared = request.prepare(); +prepared.broadcast.createTrack("video"); +const publication = prepared.publish(); + +// After +const job = request.route({ hidden: true }); +job.broadcast.createTrack("video"); +job.announce(); +``` + +`route.broadcast` throws on a prefix route, and `requested()` ends at once on an exact one. +The route metadata interface `Origin.Route` is now the read-only `Origin.RouteInfo`, carried on announce events; applications never build one. +`Origin.Table` exposes `route` and `routePrefix` in place of `createBroadcast`, `requests`, and `requestsRoute`, and `Origin.RequestSlot` and `Origin.RouteEntry` are no longer exported. + ### Opens wait their turn for stream credit ([#7894](https://github.com/moq-dev/moq/pull/7894)) -`NoStreamCredit` is removed: a request no longer fails when the peer grants no more streams. Every open waits for credit, and returning credit goes to the open with the highest `sendOrder`. A request's 10s deadline runs only while none of its streams waits to open. +`NoStreamCredit` is removed: a request no longer fails when the peer grants no more streams. Every open waits for credit, and returning credit goes to the open with the highest `sendOrder`. Request opens leave the queue when their caller cancels. `OpenOptions.timeout` and `OpenOptions.waitUntilAvailable` are removed; drop them. `Writer.tryOpen` still gives up on `cancel`, and its `sendOrder` may be a function, read again while the open waits. ### Relative group anchors are `bigint` diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 31794468923..f065c755d4f 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -11,7 +11,7 @@ export namespace Announce { export interface Announce { captures: Path.Pattern[] | undefined; prefix: Path.Valid; - route: Origin.Route; + route: Origin.RouteInfo; } export class Consumer { [Symbol.asyncIterator](): AsyncGenerator; @@ -220,6 +220,7 @@ export namespace Connection { bytesLost?: number; bytesReceived?: number; bytesSent?: number; + datagramsUnrouted?: number; estimatedSendRate?: number; packetsLost?: number; packetsReceived?: number; @@ -232,6 +233,8 @@ export namespace Connection { delay?: Milli; enabled?: boolean; maxStreams?: number; + receiveWindow?: number; + streamReceiveWindow?: number; url?: URL; } export interface WebTransportProps extends Omit { @@ -464,37 +467,19 @@ export namespace Origin { } export type Hop = infer; export const HopSchema: $ZodBranded, "Hop", "out">; - export class Prepared { - [Symbol.dispose](): void; - readonly broadcast: Broadcast.Producer; - close(): void; - publish(): Origin.Publication; - } export class Producer implements Origin.Table { constructor(); announced(scope?: Path.Pattern, options?: Announce.Options): Announce.Consumer; close(abort?: Error): void; get closed(): Signals.GetPromise; consume(): Origin.Consumer; - createBroadcast(path: Path.Valid): Origin.Publication; get discovery(): Signals.Getter; follow(path: Path.Valid, options?: Origin.FollowOptions): Origin.Following; request(path: Path.Valid, options?: Origin.RequestOptions): Origin.Requesting; - requests(prefix: Path.Valid): Origin.Requests; + route(path: Path.Valid, options?: Origin.RouteOptions): Origin.Route; + routePrefix(prefix: Path.Valid, options?: Origin.RouteOptions): Origin.Route; scope(root: Path.Valid, patterns: Path.Patterns): Origin.Producer; } - export class Publication { - announce(route?: Origin.Route | { - auth?: Origin.Route["auth"]; - cost?: Origin.Route["cost"]; - epoch?: Origin.Route["epoch"]; - hops?: Origin.Route["hops"]; - instance?: Origin.Route["instance"]; - }): void; - readonly broadcast: Broadcast.Producer; - get route(): Origin.Route | undefined; - unannounce(): void; - } export interface Reader { announced(scope?: Path.Pattern, options?: Announce.Options): Announce.Consumer; readonly closed: Signals.GetPromise; @@ -504,8 +489,8 @@ export namespace Origin { } export class Request { readonly path: Path.Valid; - prepare(): Origin.Prepared; reject(err: Error): void; + route(options?: Origin.RouteOptions): Origin.Route; serve(source: Broadcast.Producer | Broadcast.Consumer): void; } export interface RequestOptions { @@ -517,24 +502,37 @@ export namespace Origin { readonly closed: Signals.GetPromise; readonly path: Path.Valid; } - export class Requests { + export class Route { + [Symbol.dispose](): void; + announce(): void; + get announced(): boolean; + get broadcast(): Broadcast.Producer; close(): void; - readonly prefix: Path.Valid; + get instance(): Instance.Valid; requested(): AsyncIterableIterator; - get route(): Origin.Route; - } - export interface Route { + setAuth(auth?: Auth.Credential): void; + setCost(cost: Origin.Cost): void; + withdraw(): void; + } + export interface RouteInfo { + readonly auth?: Auth.Credential; + readonly cost: Origin.Cost; + readonly epoch?: bigint; + readonly hops: readonly Origin.Hop[]; + readonly instance?: Instance.Valid; + } + export interface RouteOptions { auth?: Auth.Credential; - cost: Origin.Cost; + cost?: Origin.Cost; epoch?: bigint; - hops: Origin.Hop[]; + hidden?: boolean; instance?: Instance.Valid; } export interface Table extends Origin.Reader { - createBroadcast(path: Path.Valid): Origin.Publication; - requests(prefix: Path.Valid): Origin.Requests; + route(path: Path.Valid, options?: Origin.RouteOptions): Origin.Route; + routePrefix(prefix: Path.Valid, options?: Origin.RouteOptions): Origin.Route; } - export function isAnonymous(route: Origin.Route): boolean; + export function isAnonymous(route: Origin.RouteInfo): boolean; } export namespace Path { @@ -668,7 +666,7 @@ export namespace Track { }; export class Consumer { fetchGroup(sequence: bigint, options?: Track.FetchGroupOptions): Promise; - info(): Promise; + info(options?: Track.InfoOptions): Promise; readonly name: string; subscribe(options?: Track.Subscription): Track.Subscriber; } @@ -716,6 +714,9 @@ export namespace Track { export interface InfoInit extends Partial> { maxAge?: Milli | null; } + export interface InfoOptions { + signal?: AbortSignal; + } export type Order = "ascending" | "descending"; export class Ordered { close(abort?: Error): void; @@ -820,4 +821,39 @@ export namespace Varint { export function encodeTo(dst: ArrayBuffer, v: number | bigint): Uint8Array; export function size(v: number): number; } + +export namespace socket { + export class Endpoint { + constructor(origin: Origin.Producer, prefix: Path.Valid, id: string); + acceptTcp(options?: socket.Options): Promise>; + acceptTrack(options?: socket.Options): Promise>; + close(): void; + get closed(): Signals.GetPromise; + dialTcp(peer: string, options?: socket.Options): Promise; + dialTrack(peer: string, info?: Track.InfoInit, options?: socket.Options): Promise; + readonly id: string; + readonly prefix: Path.Valid; + } + export class Incoming { + accept(...args: S extends socket.TcpStream ? [options?: socket.Options] : [info?: Track.InfoInit, options?: socket.Options]): Promise; + readonly peer: string; + reject(): void; + readonly session: string; + } + export interface Options { + signal?: AbortSignal; + } + export class TcpStream { + readonly consumer: Group.Consumer; + readonly peer: string; + readonly producer: Group.Producer; + readonly session: string; + } + export class TrackStream { + readonly consumer: Track.Subscriber; + readonly peer: string; + readonly producer: Track.Producer; + readonly session: string; + } +} ``` diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index eb38662b229..6349cdc34e5 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -4,7 +4,7 @@ import { Signal } from "@moq/signals"; import type { Grant } from "../src/auth.ts"; import { Producer as BroadcastProducer } from "../src/broadcast.ts"; import { BroadcastCache } from "../src/consume.ts"; -import { HopSchema, Route } from "../src/hop.ts"; +import { HopSchema, RouteInfo } from "../src/hop.ts"; import * as Instance from "../src/instance.ts"; import { AnnounceRequest } from "../src/lite/announce.ts"; import { Publisher } from "../src/lite/publisher.ts"; @@ -15,6 +15,7 @@ import { createMockTransportPair } from "../src/mock.ts"; import { Producer } from "../src/origin.ts"; import * as Path from "../src/path.ts"; import { Stream, Writer } from "../src/stream.ts"; +import { wireOf } from "../src/wire.ts"; // Keep protocol diagnostics out of the measured CSV on stdout. console.debug = console.error; @@ -37,7 +38,7 @@ for (const scope of [ for (const observerCount of observerCounts) { const origin = new Producer(); const handles = Array.from({ length: routeCount }, (_, index) => - origin.requests(Path.from(`room/${index}${scope === "full-name" ? ".chat" : ""}`)), + origin.routePrefix(Path.from(`room/${index}${scope === "full-name" ? ".chat" : ""}`)), ); const touched = Path.from( scope === "rooted-producers" ? "0" : scope === "full-name" ? "room/0.chat" : "room/0", @@ -75,7 +76,7 @@ for (const scope of [ const start = performance.now(); for (let index = 0; index < updates; index++) { - handles[0].updateRoute({ cost: BigInt(index + 1) }); + handles[0].setCost(BigInt(index + 1)); await new Promise((resolve) => setImmediate(resolve)); } const elapsed = performance.now() - start; @@ -132,16 +133,14 @@ for (const pinned of [false, true]) { const origin = new Producer(); const reader = origin.consume(); const options = pinned ? { instance } : {}; - const publishers = Array.from({ length: routeCount }, (_, index) => { - const sourcePublication = origin.createBroadcast(Path.from(`room/${index}`)); - sourcePublication.announce({ instance }); - return sourcePublication; - }); + const publishers = Array.from({ length: routeCount }, (_, index) => + origin.route(Path.from(`room/${index}`), { instance }), + ); const background = publishers.map((_, index) => reader.request(Path.from(`room/${index}`), options)); const requests = Array.from({ length: readerCount }, () => reader.request(Path.from("room/0"), options)); const start = performance.now(); for (let index = 0; index < updates; index++) { - publishers[0].announce({ instance, cost: BigInt(index + 1) }); + publishers[0].setCost(BigInt(index + 1)); await Promise.resolve(); for (const request of requests) { if (!request.active.peek()) throw new Error("same-instance request lost its front"); @@ -234,7 +233,7 @@ const nextInstance = Instance.parse("01900000-0000-7000-8000-000000000002"); for (const pathCount of routeCounts) { for (const readerCount of observerCounts) { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room"), { instance }); + const dynamic = origin.routePrefix(Path.from("room"), { instance }); const pending = dynamic.requested(); const requests = Array.from({ length: pathCount }, (_, index) => Array.from({ length: readerCount }, () => origin.consume().request(Path.from(`room/${index}`))), @@ -247,7 +246,7 @@ for (const pathCount of routeCounts) { await Promise.resolve(); const before = requests.map((request) => request.active.peek()); const start = performance.now(); - dynamic.updateRoute({ instance: nextInstance }); + wireOf(dynamic).update({ instance: nextInstance }); await Promise.resolve(); const elapsed = performance.now() - start; for (const [index, request] of requests.entries()) { @@ -269,7 +268,7 @@ for (const routeCount of routeCounts) { for (const consumerCount of [8, 32, 128]) { const cache = new BroadcastCache(); const owners = Array.from({ length: routeCount }, () => ({})); - for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`pool/${i}`), owners[i], Route.default); + for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`pool/${i}`), owners[i], RouteInfo.default); const sources = Array.from({ length: consumerCount }, () => new BroadcastProducer()); const held = sources.map((source, i) => { const path = Path.from(`pool/${i % routeCount}/job/${i}`); @@ -278,7 +277,7 @@ for (const routeCount of routeCounts) { const touched = Path.from("pool/0/job/0"); const start = performance.now(); for (let i = 0; i < 4096; i++) { - cache.announce(Path.from("pool/0"), owners[0], { ...Route.default, cost: BigInt(i) }); + cache.announce(Path.from("pool/0"), owners[0], { ...RouteInfo.default, cost: BigInt(i) }); const shared = cache.get(touched, cache.instance(touched)); if (!shared || shared.closed !== held[0].closed) throw new Error("update replaced the source"); shared.close(); @@ -295,7 +294,7 @@ for (const routeCount of routeCounts) { for (const consumerCount of [1, 8, 32]) { const cache = new BroadcastCache(); const owners = Array.from({ length: routeCount }, () => ({})); - for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`pool/${i}`), owners[i], Route.default); + for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`pool/${i}`), owners[i], RouteInfo.default); const sources = Array.from({ length: consumerCount }, () => new BroadcastProducer()); const paths = sources.map((_, i) => Path.from(`pool/0/job/${i}`)); const held = sources.map((source, i) => cache.insert(paths[i], cache.instance(paths[i]), source.consume())); @@ -305,7 +304,7 @@ for (const routeCount of routeCounts) { let duplicates = 0; const start = performance.now(); for (let round = 0; round < 256; round++) { - if (round % 2 === 0) cache.announce(prefix, specific, { ...Route.default, instance }); + if (round % 2 === 0) cache.announce(prefix, specific, { ...RouteInfo.default, instance }); else cache.withdraw(prefix, specific); for (let i = 0; i < consumerCount; i++) { const instance = cache.instance(paths[i]); @@ -330,7 +329,7 @@ console.log("blind,announcements,consumers,invalidate_us,stale"); for (const routeCount of routeCounts) { for (const consumerCount of [1, 8, 32]) { const cache = new BroadcastCache(); - for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`other/${i}`), {}, Route.default); + for (let i = 0; i < routeCount; i++) cache.announce(Path.from(`other/${i}`), {}, RouteInfo.default); const source = new BroadcastProducer(); const keeper = source.consume(); const paths = Array.from({ length: consumerCount }, (_, i) => Path.from(`pool/job/${i}`)); @@ -341,7 +340,7 @@ for (const routeCount of routeCounts) { for (let round = 0; round < 256; round++) { const held = paths.map((path) => cache.insert(path, cache.instance(path), source.consume())); const start = performance.now(); - cache.announce(prefix, owner, Route.default); + cache.announce(prefix, owner, RouteInfo.default); cache.withdraw(prefix, owner); elapsed += performance.now() - start; for (const path of paths) { @@ -372,7 +371,7 @@ for (const routeCount of routeCounts) { const held = []; for (let route = 0; route < routeCount; route++) { const prefix = Path.from(`route/${route}`); - cache.announce(prefix, owners[route], Route.default); + cache.announce(prefix, owners[route], RouteInfo.default); for (let consumer = 0; consumer < (route === 0 ? consumerCount : 1); consumer++) { const path = Path.from(`${prefix}/job/${consumer}`); const instance = cache.instance(path); @@ -403,9 +402,8 @@ for (const routeCount of routeCounts) { for (const observerCount of observerCounts) { const origin = new Producer(); const routes = Array.from({ length: routeCount }, (_, index) => { - const broadcastPublication = origin.createBroadcast(Path.from(`z/${index}`)); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from(`z/${index}`)); + const broadcast = broadcastRoute.broadcast; return broadcast; }); const senders = Array.from({ length: observerCount }, () => { @@ -429,9 +427,8 @@ for (const routeCount of routeCounts) { await Promise.all(senders.map((sender) => sender.entered.promise)); const start = performance.now(); for (let index = 0; index < updates; index++) { - const broadcastPublication = origin.createBroadcast(Path.from(`a/${index}`)); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from(`a/${index}`)); + const broadcast = broadcastRoute.broadcast; await new Promise((resolve) => setImmediate(resolve)); broadcast.close(); await new Promise((resolve) => setImmediate(resolve)); diff --git a/js/net/bench/fetch.ts b/js/net/bench/fetch.ts index b16a9c50d66..0728d2c2350 100644 --- a/js/net/bench/fetch.ts +++ b/js/net/bench/fetch.ts @@ -19,9 +19,8 @@ for (const groups of [2, 128]) { for (const subscribers of [1, 8]) { for (const size of [16, 1000, SEGMENT_BYTES * 2]) { const origin = new Producer(); - const publication = origin.createBroadcast(Path.from("fetch")); - publication.announce(); - const track = publication.broadcast.createTrack("video", { maxAge: Milli(60_000) }); + const route = origin.route(Path.from("fetch")); + const track = route.broadcast.createTrack("video", { maxAge: Milli(60_000) }); for (let sequence = 0; sequence < groups; sequence++) { const group = track.appendGroup(); // Only the selected group needs payloads; the rest expose cache-table scans. diff --git a/js/net/bench/fills.ts b/js/net/bench/fills.ts index df3a607ab86..498fdcb86a7 100644 --- a/js/net/bench/fills.ts +++ b/js/net/bench/fills.ts @@ -19,8 +19,7 @@ console.log("groups,subscribers,fill_us"); for (const groups of [2, 16, 128]) { for (const subscribers of [1, 8]) { const origin = new Producer(); - const publication = origin.createBroadcast(Path.from("fills")); - publication.announce(); + const publication = origin.route(Path.from("fills")); const track = publication.broadcast.createTrack("video", { maxAge: Milli(60_000) }); for (let i = 0; i < groups; i++) { const group = track.appendGroup(); diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts index 42edb11c682..9fb7807daf5 100644 --- a/js/net/bench/forward.ts +++ b/js/net/bench/forward.ts @@ -10,7 +10,7 @@ import { AuthSession } from "../src/auth_session.ts"; import { Producer as BroadcastProducer } from "../src/broadcast.ts"; import type { Established } from "../src/connection/established.ts"; import { forwardAnnounced } from "../src/connection/forward.ts"; -import { Route } from "../src/hop.ts"; +import { RouteInfo } from "../src/hop.ts"; import { Producer } from "../src/origin.ts"; import * as Path from "../src/path.ts"; import { registerWire } from "../src/wire.ts"; @@ -52,7 +52,7 @@ class FakeSession { } const announce = (stream: Announce.Producer, prefix: Path.Valid, cost: bigint, kind: "start" | "update") => - stream.append({ prefix, captures: undefined, kind, route: Route.normalize({ cost }) }); + stream.append({ prefix, captures: undefined, kind, route: RouteInfo.normalize({ cost }) }); console.log("touched,heads,routes,update_us"); for (const touched of ["covering", "single"] as const) { diff --git a/js/net/bench/refresh.ts b/js/net/bench/refresh.ts index a4ecbbad214..916d57df29e 100644 --- a/js/net/bench/refresh.ts +++ b/js/net/bench/refresh.ts @@ -16,9 +16,8 @@ for (const retained of [1, 64, 1024]) const installed = clock.install(); const source = new Producer(); const destination = new Producer(); - const broadcastPublication = source.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = source.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producers = Array.from({ length: tracks }, (_, i) => broadcast.createTrack(`data${i}`, { refresh: 1 }), ); diff --git a/js/net/bench/requests.ts b/js/net/bench/requests.ts index 5bd6a4fa82e..44e1da5645d 100644 --- a/js/net/bench/requests.ts +++ b/js/net/bench/requests.ts @@ -28,9 +28,8 @@ for (const touched of ["watched", "unrelated"]) const origin = new Producer(); const path = Path.from("room/0"); const broadcasts = Array.from({ length: routes }, (_, i) => { - const broadcastPublication = origin.createBroadcast(Path.from(`room/${i}`)); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from(`room/${i}`)); + const broadcast = broadcastRoute.broadcast; return broadcast; }); const requests = Array.from({ length: followers }, () => origin.follow(path)); @@ -41,9 +40,8 @@ for (const touched of ["watched", "unrelated"]) for (let round = 0; round < 6; round++) { const start = performance.now(); for (let i = 0; i < 128; i++) { - const nextPublication = origin.createBroadcast(touched === "watched" ? path : Path.from("other")); - const next = nextPublication.broadcast; - nextPublication.announce(); + const nextRoute = origin.route(touched === "watched" ? path : Path.from("other")); + const next = nextRoute.broadcast; if (touched === "watched") { current.close(); current = next; @@ -73,7 +71,7 @@ console.log("unrelated,waiters,cancel_us"); for (const unrelated of [8, 128, 2048]) for (const waiters of [1, 8, 32]) { const origin = new Producer(); - origin.requestsRoute(Path.from("pending")); + origin.routePrefix(Path.from("pending")); const held = Array.from({ length: unrelated }, (_, i) => origin.request(Path.from(`pending/${i}`))); const wire = wireOf(origin.consume()); const samples: number[] = []; @@ -141,9 +139,9 @@ for (const failovers of [0, 1, 16, 128]) const sources: Broadcast[] = []; for (let i = 0; i < failovers; i++) { const instance = Instance.random(); - const primary = origin.requestsRoute(prefix, { instance, cost: 0n }); + const primary = origin.routePrefix(prefix, { instance, cost: 0n }); const ar = primary.requested(); - const br = origin.requestsRoute(prefix, { instance, cost: 1n }).requested(); + const br = origin.routePrefix(prefix, { instance, cost: 1n }).requested(); const held = origin.request(path); const source = new Broadcast(); sources.push(source); @@ -153,8 +151,8 @@ for (const failovers of [0, 1, 16, 128]) entries.push({ primary, ar, br, held }); } const instance = Instance.random(); - origin.requestsRoute(prefix, { instance }); - for (let i = 0; i < extraRoutes; i++) origin.requestsRoute(prefix, { instance, cost: BigInt(i + 1) }); + origin.routePrefix(prefix, { instance }); + for (let i = 0; i < extraRoutes; i++) origin.routePrefix(prefix, { instance, cost: BigInt(i + 1) }); const pending = []; for (const entry of entries) { entry.primary.close(); @@ -216,7 +214,7 @@ for (const operation of ["cancel-forward", "cancel-reverse", "drain-local"]) const settled: number[] = []; for (let round = 0; round < 4; round++) { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("pending")); + const dynamic = origin.routePrefix(Path.from("pending")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const names = Array.from({ length: paths }, (_, i) => Path.from(`pending/${i}`)); @@ -274,7 +272,7 @@ for (const survivor of [false, true]) const origin = new Producer(); const prefix = Path.from("competing"); const path = Path.from("competing/touched"); - for (let i = 0; i < routes; i++) origin.requestsRoute(prefix, { cost: BigInt(i) }); + for (let i = 0; i < routes; i++) origin.routePrefix(prefix, { cost: BigInt(i) }); const wire = wireOf(origin.consume()); const kept = survivor ? new AbortController() : undefined; const remaining = kept ? wire.demand(path, undefined, kept.signal).catch(() => {}) : undefined; @@ -314,9 +312,8 @@ for (const [version, alpn] of [ const sources = []; for (const prefix of ["a", "b"]) for (let i = 0; i < namespaces; i++) { - const sourcePublication = origin.createBroadcast(Path.from(`${prefix}/${i}`)); - const source = sourcePublication.broadcast; - sourcePublication.announce(); + const sourceRoute = origin.route(Path.from(`${prefix}/${i}`)); + const source = sourceRoute.broadcast; sources.push(source); } const peers = await Promise.all( diff --git a/js/net/bench/resume-history.ts b/js/net/bench/resume-history.ts index a5e2d63bd63..fd417fbf752 100644 --- a/js/net/bench/resume-history.ts +++ b/js/net/bench/resume-history.ts @@ -216,8 +216,7 @@ async function measureOn(clock: Manual, { fronts, names: perRoute, routes, histo const publisher = new OriginProducer(); const paths = Array.from({ length: fronts }, (_, index) => Path.from(`room/${index}`)); const broadcasts: BroadcastProducer[] = paths.map((path) => { - const publication = publisher.createBroadcast(path); - publication.announce(); + const publication = publisher.route(path); return publication.broadcast; }); const payload = new Uint8Array(PAYLOAD).fill(7); diff --git a/js/net/bench/serve-browser-entry.ts b/js/net/bench/serve-browser-entry.ts index f70d07346b9..bde56cddf01 100644 --- a/js/net/bench/serve-browser-entry.ts +++ b/js/net/bench/serve-browser-entry.ts @@ -17,9 +17,8 @@ import { Timescale, Timestamp } from "../src/time.ts"; async function measure(protocol: string, groups: number, viewers: number) { const origin = new Origin(); - const broadcastPublication = origin.createBroadcast(Path.from("bench")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("bench")); + const broadcast = broadcastRoute.broadcast; const track = broadcast.createTrack("data", { maxAge: Milli(60_000), timescale: Timescale.MICRO }); const pair = createMockTransportPair(protocol === "lite" ? ALPN_05 : ALPN.DRAFT_22); const version = protocol === "lite" ? LiteVersion.DRAFT_05 : IetfVersion.DRAFT_22; diff --git a/js/net/bench/socket.ts b/js/net/bench/socket.ts new file mode 100644 index 00000000000..fef5e678251 --- /dev/null +++ b/js/net/bench/socket.ts @@ -0,0 +1,85 @@ +/** Per-session socket handshake, frame delivery and teardown swept over endpoints x sessions per endpoint. */ +import { yieldNow } from "@moq/clock"; +import { accept } from "../src/connection/accept.ts"; +import { connect } from "../src/connection/connect.ts"; +import { Origin, Path, socket } from "../src/index.ts"; +import * as Lite from "../src/lite/index.ts"; +import { createMockTransportPair } from "../src/mock.ts"; + +console.debug = () => {}; +console.log("mode,protocol,endpoints,sessions_per_endpoint,open_us,frame_us,close_us"); +for (const mode of ["local", "lite07"] as const) + for (const protocol of ["tcp", "track"] as const) + for (const endpoints of [1, 8, 32]) + for (const sessions of [1, 8, 32]) { + const samples: number[][] = []; + for (let round = 0; round < 4; round++) { + const origin = new Origin.Producer(); + const remote = mode === "local" ? origin : new Origin.Producer(); + const pair = mode === "lite07" ? createMockTransportPair(Lite.ALPN_07_WIP) : undefined; + const connections = pair + ? await Promise.all([ + connect({ + url: new URL("https://localhost/bench"), + transport: pair.client, + publish: origin.consume(), + consume: origin, + }), + accept({ + url: new URL("https://localhost/bench"), + transport: pair.server, + publish: remote.consume(), + consume: remote, + }), + ]) + : []; + const dialer = new socket.Endpoint(origin, Path.from("bench"), "dialer"); + const peers = Array.from( + { length: endpoints }, + (_, i) => new socket.Endpoint(remote, Path.from("bench"), `peer-${i}`), + ); + const start = performance.now(); + const pairs = await Promise.all( + peers.flatMap((peer) => + Array.from({ length: sessions }, async () => { + const incoming = protocol === "tcp" ? peer.acceptTcp() : peer.acceptTrack(); + const dialing = + protocol === "tcp" ? dialer.dialTcp(peer.id) : dialer.dialTrack(peer.id); + return Promise.all([dialing, (await incoming).accept()]); + }), + ), + ); + const opened = performance.now(); + await Promise.all( + pairs.map(async ([local, remote]) => { + local.producer.writeFrame({ payload: new Uint8Array(1024) }); + const frame = + remote instanceof socket.TcpStream + ? await remote.consumer.readFrame() + : await remote.consumer.ordered().readFrame(); + if (frame?.payload.length !== 1024) throw new Error("missing socket payload"); + }), + ); + const received = performance.now(); + dialer.close(); + for (const peer of peers) peer.close(); + origin.close(); + remote.close(); + for (const connection of connections) connection.abort(); + await yieldNow(); + const closed = performance.now(); + if (round) + samples.push( + [opened - start, received - opened, closed - received].map( + (ms) => (ms * 1000) / pairs.length, + ), + ); + } + const median = [0, 1, 2].map((i) => + samples + .map((sample) => sample[i]) + .sort((a, b) => a - b)[1] + .toFixed(2), + ); + console.log(`${mode},${protocol},${endpoints},${sessions},${median.join(",")}`); + } diff --git a/js/net/bench/subscribe-drop.ts b/js/net/bench/subscribe-drop.ts index 892a8d9a7c0..bf4d9af8f59 100644 --- a/js/net/bench/subscribe-drop.ts +++ b/js/net/bench/subscribe-drop.ts @@ -23,9 +23,8 @@ try { for (let rep = 0; rep < reps; rep++) { const origins = Array.from({ length: publishers }, () => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("bench")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("bench")); + const broadcast = broadcastRoute.broadcast; const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); for (let sequence = 0n; sequence < BigInt(groups); sequence++) { const group = track.createGroup(sequence); diff --git a/js/net/examples/publish.ts b/js/net/examples/publish.ts index 90d90d6be64..7b1b7b27d09 100644 --- a/js/net/examples/publish.ts +++ b/js/net/examples/publish.ts @@ -7,10 +7,9 @@ async function main() { const origin = new Moq.Origin.Producer(); const connection = await Moq.Connection.connect({ url, publish: origin.consume() }); - // Create a broadcast (a collection of tracks) at a path on the origin - const broadcastPublication = origin.createBroadcast(Moq.Path.from("my-broadcast")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + // Route a path on the origin. The route owns a broadcast (a collection of tracks) and announces it. + const route = origin.route(Moq.Path.from("my-broadcast")); + const broadcast = route.broadcast; // Create the "chat" track up front. A subscriber is served directly from this // track, no requested() round-trip needed. Mirrors Rust's create_track. diff --git a/js/net/src/announced.test-util.ts b/js/net/src/announced.test-util.ts index 6f2b2732d87..5b36855a195 100644 --- a/js/net/src/announced.test-util.ts +++ b/js/net/src/announced.test-util.ts @@ -1,6 +1,6 @@ import { Signal } from "@moq/signals"; import type { Options, Routes } from "./announced"; -import type { Route } from "./hop"; +import type { RouteInfo } from "./hop"; import type * as Path from "./path"; /** A closeable announcement snapshot for tests and benchmarks. @internal */ @@ -10,7 +10,7 @@ export async function announcedView( options?: Options, ) { const stream = origin.routes(scope, options); - const value = new Signal>(new Map()); + const value = new Signal>(new Map()); const done = (async () => { for await (const event of stream) value.update((previous) => { diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index eb2ce44fe0e..87cd1fc24e2 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -2,15 +2,14 @@ import { expect, test } from "bun:test"; import { yieldNow } from "@moq/clock"; import * as Announce from "./announced.ts"; import type { Producer as BroadcastProducer } from "./broadcast.ts"; -import { Route } from "./hop.ts"; +import { RouteInfo } from "./hop.ts"; import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; import { wireOf } from "./wire.ts"; function publish(origin: OriginProducer, path: Path.Valid): BroadcastProducer { - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; return broadcast; } @@ -20,7 +19,7 @@ test("next streams every appended event in order", async () => { const producer = new Announce.Producer(); const consumer = producer.consume(); - const route = Route.default; + const route = RouteInfo.default; producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); producer.append({ prefix: p("a"), captures: undefined, kind: "end", route }); @@ -32,7 +31,7 @@ test("a listing reports only a path becoming present or absent", async () => { const producer = new Announce.Producer(); const listing = Announce.list(producer.consume()); - const route = Route.default; + const route = RouteInfo.default; producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); producer.append({ prefix: p("a"), captures: undefined, kind: "update", route }); producer.append({ prefix: p("a"), captures: undefined, kind: "restart", route }); @@ -46,7 +45,7 @@ test("the consumer is an async iterable of the same events", async () => { const producer = new Announce.Producer(); const consumer = producer.consume(); - const route = Route.default; + const route = RouteInfo.default; producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); producer.append({ prefix: p("a"), captures: undefined, kind: "end", route }); @@ -65,7 +64,7 @@ test("a same-name re-announce is a distinct update", async () => { // The stream is a log, not a set: it carries a redundant announce as its own update rather // than collapsing it. Deciding what a repeat means belongs to the session layer, which resolves // a restart into either nothing (an identical route) or an in-place update. - const route = Route.default; + const route = RouteInfo.default; producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); @@ -98,7 +97,7 @@ test("closing a consumer releases only that handle", async () => { expect(producer.closed.peek()).toBeUndefined(); // The other reader keeps the stream: an event still reaches it. - const route = Route.default; + const route = RouteInfo.default; producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); expect((await second.next())?.prefix).toBe(p("a")); @@ -179,7 +178,7 @@ const start = (prefix: string): Announce.RouteEvent => ({ prefix: p(prefix), captures: undefined, kind: "start", - route: Route.default, + route: RouteInfo.default, }); test("a stream syncs once the reader is past what was queued at the boundary", async () => { diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 20db6927bfc..f779130dd94 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -4,7 +4,7 @@ * @module */ import { type GetPromise, type Getter, Once, Signal } from "@moq/signals"; -import type { Route } from "./hop.js"; +import type { RouteInfo } from "./hop.js"; import type * as Path from "./path.js"; /** @@ -26,7 +26,7 @@ export interface Announce { /** What the filter's wildcards stood for, when this prefix pins all of them. */ captures: Path.Pattern[] | undefined; /** Hops and cost of the route; on a retraction, its last advertised values. */ - route: Route; + route: RouteInfo; } /** diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 0ca98b10dc6..5e1708feca9 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -311,8 +311,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { })(); await waitFor(client.auth.grant, (g) => g !== undefined); - origin.createBroadcast(Path.from("baz/ok")).announce(); - origin.createBroadcast(Path.from("foo/bar")).announce(); + origin.route(Path.from("baz/ok"), { hidden: true }).announce(); + origin.route(Path.from("foo/bar"), { hidden: true }).announce(); const info = await transport.closed; expect(info.closeCode).toBe(SessionCode.Unauthorized); @@ -332,15 +332,14 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); - const withdrawn = origin.createBroadcast(Path.from("foo/bar")); - withdrawn.announce(); + const withdrawn = origin.route(Path.from("foo/bar")); await yieldNow(); - withdrawn.unannounce(); + withdrawn.withdraw(); await yieldNow(); const issued = unanswered.accept(grant(["other"], [])); await adding; // Still enforcing: the next uncovered broadcast is the one named. - origin.createBroadcast(Path.from("still-bad")).announce(); + origin.route(Path.from("still-bad"), { hidden: true }).announce(); const info = await transport.closed; expect(info.closeCode).toBe(SessionCode.Unauthorized); expect(info.reason).toBe("unauthorized: still-bad"); @@ -367,7 +366,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { })(); await waitFor(client.auth.grant, (g) => g !== undefined); - origin.createBroadcast(Path.from("a/x")).announce(); + origin.route(Path.from("a/x"), { hidden: true }).announce(); const announced = server.routes(); const first = await nextRoute(announced); @@ -459,9 +458,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { const installed = clock.install(); const logged = spyOn(console, "debug"); const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room/x")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room/x")); + const broadcast = broadcastRoute.broadcast; const published = broadcast.createTrack("video"); published.appendGroup().writeFrame({ payload: new Uint8Array([1]) }); const { client, server } = await connect({ serverPublish: origin, protocol }); @@ -527,7 +525,9 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); - const track = new OriginProducer().createBroadcast(Path.from("room/x")).broadcast.createTrack("video"); + const track = new OriginProducer() + .route(Path.from("room/x"), { hidden: true }) + .broadcast.createTrack("video"); const reader = track.subscribe(); const ready = wireOf(client).auth.ready(Path.from("room/x"), "subscribe", { until: () => abandoned(track.demand()), @@ -552,9 +552,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { test("a subscribe abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room/x")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room/x")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("video"); const { client, server, transport } = await connect({ serverPublish: origin, protocol }); const requests = server.auth.requests(); @@ -591,9 +590,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { "a subscribe that regains a reader while its token waits keeps waiting (%d)", async (gap) => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room/x")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room/x")); + const broadcast = broadcastRoute.broadcast; const published = broadcast.createTrack("video"); published.appendGroup().writeFrame({ payload: new Uint8Array([7]) }); const { client, server } = await connect({ serverPublish: origin, protocol }); @@ -628,9 +626,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { test("a session's close is not a revocation of its subscriptions", async () => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room/x")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room/x")); + const broadcast = broadcastRoute.broadcast; broadcast .createTrack("video") .appendGroup() @@ -1020,9 +1017,8 @@ test("lite-07 ignores a draining session's late refusal while its successor retr test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room/x")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room/x")); + const broadcast = broadcastRoute.broadcast; broadcast .createTrack("video") .appendGroup() @@ -1064,9 +1060,8 @@ test("a fetch abandoned while its token waits never reaches the wire", async () // The peer refuses a listing its token has not yet authorized, so the request waits for it. test("a listing waits for its token before reaching the wire", async () => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room/x")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room/x")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("video"); const { client, server, transport } = await connect({ serverPublish: origin, protocol: Lite.ALPN_07_WIP }); const requests = server.auth.requests(); @@ -1102,9 +1097,8 @@ test.each(Array.from({ length: 17 }, (_, gap) => gap))( "a fetch that regains a caller while its token waits keeps waiting (%d)", async (gap) => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room/x")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room/x")); + const broadcast = broadcastRoute.broadcast; broadcast .createTrack("video") .appendGroup() @@ -1274,15 +1268,13 @@ test.each([ // moq-transport has no stream code for it, so only moq-lite resets with UNAUTHORIZED. test("a revoked grant resets its subscriptions with UNAUTHORIZED", async () => { const clientOrigin = new OriginProducer(); - const upPublication = clientOrigin.createBroadcast(Path.from("up/y")); - const up = upPublication.broadcast; - upPublication.announce(); + const upRoute = clientOrigin.route(Path.from("up/y")); + const up = upRoute.broadcast; const upTrack = up.createTrack("video"); const serverOrigin = new OriginProducer(); - const downPublication = serverOrigin.createBroadcast(Path.from("room/x")); - const down = downPublication.broadcast; - downPublication.announce(); + const downRoute = serverOrigin.route(Path.from("room/x")); + const down = downRoute.broadcast; const downTrack = down.createTrack("video"); const { client, server } = await connect({ diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index bf6adef6fe5..0215b901ad0 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -236,6 +236,7 @@ test("held info lookups keep a request until the last one lets go", async () => const opened = wireOf(broadcast).resolveTrackInfo("media", first.signal); const request = await pulled; if (!request) throw new Error("expected request"); + expect(hooks.requestWanted(request).peek()).toBe(true); const producer = request.accept(); const second = new AbortController(); await wireOf(broadcast).resolveTrackInfo("media", second.signal); @@ -246,6 +247,7 @@ test("held info lookups keep a request until the last one lets go", async () => expect(producer.closed.peek()).toBeUndefined(); second.abort(); + expect(hooks.requestWanted(request).peek()).toBe(false); expect(producer.closed.peek()).toBeDefined(); broadcast.close(); }); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index e07d002d8ca..e562959b4de 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -7,7 +7,6 @@ import { type GetPromise, Once, Signal } from "@moq/signals"; import * as datagram from "./datagram.ts"; import { NotFound, StreamCode, StreamError, toStreamCode } from "./error.ts"; import type { Consumer as GroupConsumer } from "./group.ts"; -import type { Route } from "./hop.ts"; import type * as Instance from "./instance.ts"; import { hooks } from "./internal.ts"; import { Listing, listTracks, makeLister, type Tracks } from "./listing.ts"; @@ -18,22 +17,11 @@ import { registerWire, trackOf, wireOf } from "./wire.ts"; export { Tracks, type TracksEvent } from "./listing.ts"; -/** The origin callback a created broadcast uses to advertise its exact path. @internal */ -export interface Announcer { - /** Advertise or re-price this broadcast's path. */ - announce(route: Route): void; - /** Retract the advertisement from local consumers and peers alike. */ - unannounce(): void; - /** The route this broadcast's path is advertised with, if it is. */ - route(): Route | undefined; -} - -let attachAnnouncer: (producer: Producer, announcer: Announcer) => void; let stampProducer: (producer: Producer, path: Path.Valid) => void; // Info lookups pending or held on a track. class Lookups { - pending = 0; + pending = new Signal(0); // Whether a lookup opened the request, so the last lookup to end closes it unless subscribed. opened = false; } @@ -232,7 +220,7 @@ function logical(state: BroadcastState, name: string): { producer: track.Produce }); state.requested.mutate((requested) => { - requested.push(hooks.makeRequest({ name, producer, pending: state.pending })); + requested.push(hooks.makeRequest({ name, producer, pending: state.pending, lookups })); }); return { producer, requested: true }; @@ -242,21 +230,21 @@ function subscribe(state: BroadcastState, name: string, options?: track.Subscrip return logical(state, name).producer.subscribe(options); } -// A lookup is held until `hold` aborts, which also abandons it before the answer. A request a lookup opened is let go once it is answered and nobody holds or subscribes -// to it; closing it sooner would hand its handler a closed producer. +// A lookup holds a request until `hold` aborts, even before the answer. An unanswered +// request stays open for its handler; after acceptance nobody holding it releases it. async function resolveTrackInfo(state: BroadcastState, name: string, hold?: AbortSignal): Promise { hold?.throwIfAborted(); const { producer, requested } = logical(state, name); const lookups = state.lookups.get(producer); if (!lookups) return producer.info(); if (requested) lookups.opened = true; - lookups.pending++; + lookups.pending.update((pending) => pending + 1); const info = producer.info(); let answered = false; let ended = false; const release = () => { - if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) + if (lookups.opened && lookups.pending.peek() === 0 && producer.demand().wanted.peek() === undefined) hooks.releaseTrack(producer); }; info.then( @@ -268,7 +256,7 @@ async function resolveTrackInfo(state: BroadcastState, name: string, hold?: Abor ); const end = () => { ended = true; - lookups.pending--; + lookups.pending.update((pending) => pending - 1); if (answered) release(); }; @@ -386,7 +374,6 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info */ export class Producer { #state = new BroadcastState(); - #announcer?: Announcer; #path = Path.empty(); constructor() { @@ -406,10 +393,19 @@ export class Producer { } static { - attachAnnouncer = (producer, announcer) => { - producer.#announcer = announcer; + hooks.reserveUnlistedTrack = (broadcast, name) => { + const state = broadcast.#state; + if (state.closed.peek() !== undefined) throw new Error("broadcast is closed"); + if (lookup(state, name)) throw new Error("duplicate reserved track"); + const producer = new track.Producer(name); + const lookups = new Lookups(); + state.lookups.set(producer, lookups); + state.requests.set(name, producer); + void hooks.trackReleased(producer).then(() => { + if (state.requests.get(name) === producer) state.requests.delete(name); + }); + return hooks.makeRequest({ name, producer, pending: state.pending, lookups }); }; - hooks.attachAnnouncer = attachAnnouncer; hooks.onClosed = (producer, fn) => { const closers = producer.#state.closers; @@ -452,9 +448,7 @@ export class Producer { const request = takeQueued(this.#state, name); if (request) { const producer = request.accept(info); - // The application owns it now: a lookup that opened the request must not close it. - const lookups = this.#state.lookups.get(producer); - if (lookups) lookups.opened = false; + hooks.keepRequest(request); this.#state.requests.delete(name); adopt(this.#state, producer, track.authoredInfo(info)); return producer; @@ -493,10 +487,8 @@ export class Producer { return trackOf(name, this); } - /** End the broadcast for good: retract its route and serve no new tracks. Idempotent. */ + /** End the broadcast for good: serve no new tracks. Idempotent. */ close(): void { - this.#announcer?.unannounce(); - this.#announcer = undefined; closeState(this.#state); } } diff --git a/js/net/src/connection/close.test.ts b/js/net/src/connection/close.test.ts index 2111a17e097..d15c7c770bf 100644 --- a/js/net/src/connection/close.test.ts +++ b/js/net/src/connection/close.test.ts @@ -57,9 +57,8 @@ for (const protocol of [Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18]) { const fin = holdFin(pair.server); const source = new Producer(); const destination = new Producer(); - const broadcastPublication = source.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = source.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const url = new URL("https://localhost/test"); const [client, server] = await Promise.all([ connect({ url, transport: pair.client, consume: destination }), diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index d69fa3b64e7..1a8d2b5e234 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -647,13 +647,24 @@ test("the WebSocket fallback offers the same versions", async () => { expect(asked.some((protocol) => protocol.endsWith(ALPN_05))).toBe(false); }); +// Read wire parameters without making qmux's private codec part of net's TS project. +const { decodeRecord } = require("../../../qmux/src/frame.ts"); + +interface ReceiveWindows { + initialMaxData: bigint; + initialMaxStreamDataBidiLocal: bigint; + initialMaxStreamDataBidiRemote: bigint; + initialMaxStreamDataUni: bigint; +} + // How many streams the WebSocket fallback lets a server open in each direction: a qmux // server accepts the dial and opens streams until the client's limit refuses one. -async function websocketStreamLimits(props: Omit): Promise<{ bidi: number; uni: number }> { +async function websocketStreamLimits(props: Omit) { const original = globalThis.WebTransport; // No WebTransport, so the WebSocket attempt goes at once. globalThis.WebTransport = undefined as unknown as typeof WebTransport; const accepted = Promise.withResolvers(); + const parameters = Promise.withResolvers(); // Bun's server socket, shaped as the `WebSocketLike` that `Qmux.accept` adopts. let adopted: WebSocketLike | undefined; const server = Bun.serve<{ protocol: string }>({ @@ -691,6 +702,10 @@ async function websocketStreamLimits(props: Omit): Promise< accepted.resolve(Qmux.accept(adopted, { protocol: socket.data.protocol })); }, message(_socket, data) { + if (typeof data === "string") throw new Error("expected binary QMux record"); + for (const frame of decodeRecord(new Uint8Array(data))) { + if (frame.type === "transport_parameters") parameters.resolve(frame.params); + } adopted?.onmessage?.({ data }); }, close(_socket, code, reason) { @@ -723,21 +738,50 @@ async function websocketStreamLimits(props: Omit): Promise< controller.abort(); await attempt; session.close(); - return limits; + return { ...limits, params: await parameters.promise }; } finally { globalThis.WebTransport = original; server.stop(true); } } -test("the WebSocket fallback grants the server websocket.maxStreams in each direction", async () => { - expect(await websocketStreamLimits({})).toEqual({ bidi: 10_000, uni: 10_000 }); - expect(await websocketStreamLimits({ websocket: { maxStreams: 3 } })).toEqual({ bidi: 3, uni: 3 }); +test("the WebSocket fallback grants stream limits and receive windows", async () => { + const defaults = await websocketStreamLimits({}); + expect(defaults).toMatchObject({ + bidi: 10_000, + uni: 10_000, + params: { + initialMaxData: 4_194_304n, + initialMaxStreamDataBidiLocal: 1_048_576n, + initialMaxStreamDataBidiRemote: 1_048_576n, + initialMaxStreamDataUni: 1_048_576n, + }, + }); + expect( + await websocketStreamLimits({ + websocket: { + maxStreams: 3, + receiveWindow: 65_537, + streamReceiveWindow: 16_385, + }, + }), + ).toMatchObject({ + bidi: 3, + uni: 3, + params: { + initialMaxData: 65_537n, + initialMaxStreamDataBidiLocal: 16_385n, + initialMaxStreamDataBidiRemote: 16_385n, + initialMaxStreamDataUni: 16_385n, + }, + }); }); -test("websocket.maxStreams must be a positive integer", async () => { - for (const maxStreams of [0, -1, 1.5, Number.NaN]) { - await expect(connect(url, { websocket: { maxStreams } })).rejects.toThrow(RangeError); +test("WebSocket limits must be positive safe integers", async () => { + for (const key of ["maxStreams", "receiveWindow", "streamReceiveWindow"] as const) { + for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await expect(connect(url, { websocket: { [key]: value } })).rejects.toThrow(RangeError); + } } }); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 27298bf1831..abd98caa73f 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -40,6 +40,10 @@ const WEBSOCKET_REDIAL = Milli(10_000); // `moq-tokio`'s `quic.max_streams`, the limit of Rust's sessions and the relay. const DEFAULT_MAX_STREAMS = 10_000; +// Match moq-tokio so WebSocket readers have the same bandwidth-delay budget. +const DEFAULT_RECEIVE_WINDOW = 4 << 20; +const DEFAULT_STREAM_RECEIVE_WINDOW = 1 << 20; + // RESET_STREAM and CONNECTION_CLOSE are separate transport notifications. Give the latter // time to surface its authoritative code without letting a reset-only peer stall SETUP forever. const SETUP_CLOSE_GRACE = Nano.fromMilli(Milli(100)); @@ -66,6 +70,12 @@ export interface WebSocketProps { * Must be a positive integer. */ maxStreams?: number; + + /** Connection receive window in bytes (default: 4 MiB). Must be a positive safe integer. */ + receiveWindow?: number; + + /** Per-stream receive window in bytes (default: 1 MiB). Must be a positive safe integer. */ + streamReceiveWindow?: number; } /** @@ -177,9 +187,11 @@ export function normalizeVersions(props?: { throw new TypeError("webtransport.protocols was removed; set versions instead"); if (props?.websocket && "protocols" in props.websocket) throw new TypeError("websocket.protocols was removed; set versions instead"); - const maxStreams = props?.websocket?.maxStreams; - if (maxStreams !== undefined && !(Number.isSafeInteger(maxStreams) && maxStreams > 0)) - throw new RangeError(`websocket.maxStreams must be a positive integer: ${maxStreams}`); + for (const key of ["maxStreams", "receiveWindow", "streamReceiveWindow"] as const) { + const value = props?.websocket?.[key]; + if (value !== undefined && !(Number.isSafeInteger(value) && value > 0)) + throw new RangeError(`websocket.${key} must be a positive safe integer: ${value}`); + } return props?.versions === undefined ? undefined : checkVersions(props.versions); } @@ -441,7 +453,14 @@ async function connectInner( // wire-format version. protocols: offered.alpns, versions: Object.fromEntries(offered.alpns.map((alpn) => [alpn, QMUX[alpn] ?? null])), - config: { maxStreamsBidi: maxStreams, maxStreamsUni: maxStreams }, + config: { + maxStreamsBidi: maxStreams, + maxStreamsUni: maxStreams, + receiveWindow: BigInt(props.websocket?.receiveWindow ?? DEFAULT_RECEIVE_WINDOW), + streamReceiveWindow: BigInt( + props.websocket?.streamReceiveWindow ?? DEFAULT_STREAM_RECEIVE_WINDOW, + ), + }, }) : undefined; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 79a9df2030a..396e9c3b40a 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -3,7 +3,7 @@ import { type Installed, Manual, Milli, Nano } from "@moq/clock"; import * as Announce from "../announced.ts"; import { AuthSession } from "../auth_session.ts"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "../broadcast.ts"; -import { Route } from "../hop.ts"; +import { RouteInfo } from "../hop.ts"; import * as Instance from "../instance.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; @@ -132,7 +132,7 @@ class FakeSession { } } -function start(stream: Announce.Producer, prefix: string, route: Route = Route.default) { +function start(stream: Announce.Producer, prefix: string, route: RouteInfo = RouteInfo.default) { stream.append({ prefix: Path.from(prefix), captures: undefined, kind: "start", route }); } @@ -411,12 +411,12 @@ for (const [name, instances] of [ const narrow = origin.routes(Path.Pattern.subtree(Path.from("a/b"))); await settle(); - start(session.stream("a/b/**"), "a/b/x", { ...Route.default, instance: instances[0] }); + start(session.stream("a/b/**"), "a/b/x", { ...RouteInfo.default, instance: instances[0] }); await settle(); const broad = origin.routes(Path.Pattern.subtree(Path.from("a"))); const events = record(broad); await settle(); - start(session.stream("a/**"), "a/b/x", { ...Route.default, instance: instances[1] }); + start(session.stream("a/**"), "a/b/x", { ...RouteInfo.default, instance: instances[1] }); await settle(); expect(events).toEqual(["start a/b/x"]); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 99ea9c338f7..bb2cfb6ba6d 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -7,12 +7,12 @@ import { Milli, Nano } from "@moq/clock"; import { type Dispose, race } from "@moq/signals"; import type * as Announce from "../announced.ts"; -import { type Route, routesEqual } from "../hop.ts"; +import { type RouteInfo, routesEqual } from "../hop.ts"; import { coveringPrefixes } from "../internal.ts"; -import type { Producer as OriginProducer, RequestSlot, Requests } from "../origin.ts"; +import type { Producer as OriginProducer, Route } from "../origin.ts"; import * as Path from "../path.ts"; import { after } from "../util/timer.ts"; -import { wireOf } from "../wire.ts"; +import { type RequestSlot, wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; /** Why a connection refuses the removed `discovery` option rather than ignoring it. */ @@ -39,9 +39,9 @@ interface Interest { * request handing over to another never retracts it. The first reporter drives it. */ interface Reported { - handle: Requests; + handle: Route; /** Each request's latest route for the prefix. */ - readonly reporters: Map; + readonly reporters: Map; } /** @@ -118,16 +118,17 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi * instance, so nothing resolved through the old one is joined again, and an in-place * update otherwise. */ - const updateReported = (existing: Reported, prefix: Path.Valid, route: Route, restart: boolean) => { - if (restart || route.epoch !== existing.handle.route.epoch) { + const updateReported = (existing: Reported, prefix: Path.Valid, route: RouteInfo, restart: boolean) => { + const current = wireOf(existing.handle).info; + if (restart || route.epoch !== current?.epoch) { // Inserted before the old one leaves, so local readers see one restart rather than // an end and a start. const handle = originWire.receive(prefix, route); existing.handle.close(); existing.handle = handle; void drive(handle, conn); - } else if (!routesEqual(existing.handle.route, route)) { - existing.handle.updateRoute(route); + } else if (!routesEqual(current, route)) { + wireOf(existing.handle).update(route); } }; @@ -289,7 +290,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi } /** Answer the requests under a received route by consuming them from the session. */ -async function drive(handle: Requests, conn: Established): Promise { +async function drive(handle: Route, conn: Established): Promise { const session = wireOf(conn); try { for await (const request of handle.requested()) { diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index 0eaef291ca2..eede71cf3e2 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -79,9 +79,8 @@ test("an empty GOAWAY migrates the follower to the replacement session", async ( const clock = new Manual(); const installed = clock.install(); const { dials, origin } = fleet(); - const broadcastPublication = origin.createBroadcast(Path.from("cam")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("cam")); + const broadcast = broadcastRoute.broadcast; const consume = new OriginProducer(); const reload = new Reload({ diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index d62e72772e2..e9dc3a9d313 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -13,11 +13,8 @@ import { type AcceptProps, accept as acceptSession } from "./index.ts"; import { Connection, resetShared } from "./pool.ts"; import { Reload } from "./reload.ts"; -function publish(origin: Pick, path: Path.Valid) { - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); - return broadcast; +function publish(origin: Pick, path: Path.Valid) { + return origin.route(path).broadcast; } const url = new URL("https://example.com/pool"); @@ -262,7 +259,7 @@ test("a borrowed connection origin exposes live routes and dynamic serving", asy announcement = event; }); const prefix = Path.from("on-demand"); - const route = table.requestsRoute(prefix); + const route = table.routePrefix(prefix); await advance(); expect(announcement).toMatchObject({ kind: "present", prefix }); let withdrawal: unknown; diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 80e37e1e321..4b36caa5dcd 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -5,7 +5,7 @@ import * as Announce from "../announced.ts"; import { Viewers } from "../bandwidth.ts"; import type { Producer as BroadcastProducer } from "../broadcast.ts"; import { ConnectTimeout, SessionCode, SessionError, StreamCode, toTransport } from "../error.ts"; -import { Route } from "../hop.ts"; +import { RouteInfo } from "../hop.ts"; import { ALPN as IetfALPN } from "../ietf/version.ts"; import * as Lite from "../lite/index.ts"; import { createMockTransportPair, type MockTransport, textFrame } from "../mock.ts"; @@ -25,9 +25,8 @@ function accept(transport: WebTransport, url: URL, props: Omit handle.origin.peek() !== undefined); const origin = handle.origin.peek(); if (!origin) throw new Error("no origin"); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); await waitUntil(() => dials.length > 0); const first = dials[0]; @@ -835,9 +833,8 @@ for (const [name, drainEnd] of Object.entries(drainEnds)) { globalThis.WebTransport = stub as unknown as typeof WebTransport; const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const reload = new Reload({ url: new URL("https://example.com/a"), @@ -896,9 +893,8 @@ test("a retired GOAWAY predecessor still closes by its handover cap", async () = globalThis.WebTransport = stub as unknown as typeof WebTransport; const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const reload = new Reload({ url: new URL("https://example.com/"), @@ -945,7 +941,8 @@ test("a retired GOAWAY predecessor still closes by its handover cap", async () = // A publication only a token's grant covers keeps that grant while the session drains, so // closing the loop ends the tokens after the drain rather than cutting the final group. -test("closing the loop keeps the tokens until the session drains", async () => { +// Quarantined pending quest/m0/test-flakes-2/reload-close-drain.md. +test.skip("closing the loop keeps the tokens until the session drains", async () => { const original = globalThis.WebTransport; const clock = new Manual(); const installed = clock.install(); @@ -980,9 +977,8 @@ test("closing the loop keeps the tokens until the session drains", async () => { const origin = new OriginProducer(); const reload = new Reload({ url, websocket: { enabled: false }, publish: origin.consume() }); await reload.auth.add("token"); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const first = dials[0]; if (!first) throw new Error("no first dial"); @@ -1034,7 +1030,7 @@ test("closing an announce consumer during upstream teardown does not append retr prefix: Path.from("alice/camera.hang"), captures: undefined, kind: "start", - route: Route.default, + route: RouteInfo.default, }); await consumer.next(); upstream.close(); @@ -1140,7 +1136,7 @@ test("a pump owes the retractions of the upstream it replaces", async () => { const consumer = producer.consume(); const first = new Announce.Producer(); - first.append({ prefix: Path.from("a"), captures: undefined, kind: "start", route: Route.default }); + first.append({ prefix: Path.from("a"), captures: undefined, kind: "start", route: RouteInfo.default }); first.synced(); const upstream = new Signal(first); const pump = new Effect((effect) => pumpAnnounced(effect, effect.get(upstream).consume(), producer)); diff --git a/js/net/src/connection/scope.test.ts b/js/net/src/connection/scope.test.ts index c59a729acf1..8c6e0b204c3 100644 --- a/js/net/src/connection/scope.test.ts +++ b/js/net/src/connection/scope.test.ts @@ -22,9 +22,8 @@ for (const protocol of [Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18]) { new Path.Patterns(["room/**", "other/**"].map(Path.Pattern.parse)), ); const broadcasts = ["room/live", "room/.hidden", "other/live", "outside"].map((path) => { - const broadcastPublication = publish.createBroadcast(Path.from(path)); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = publish.route(Path.from(path)); + const broadcast = broadcastRoute.broadcast; return broadcast; }); const [client, server] = await Promise.all([ diff --git a/js/net/src/connection/stats.test.ts b/js/net/src/connection/stats.test.ts index 87117c84316..3e42174d605 100644 --- a/js/net/src/connection/stats.test.ts +++ b/js/net/src/connection/stats.test.ts @@ -76,14 +76,15 @@ test("stats() snapshots the transport on demand", async () => { } }); -test("stats() is empty on a transport without getStats", async () => { +test("stats() has no transport counters on a transport without getStats", async () => { const pair = createMockTransportPair(Lite.ALPN_06); // Shadow the mock's getStats to imitate the qmux/WebSocket fallback. (pair.client as unknown as { getStats?: unknown }).getStats = undefined; const url = new URL("https://example.com/"); const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); try { - expect(await client.stats()).toEqual({}); + // Only the session's own counter, which needs nothing from the transport. + expect(await client.stats()).toEqual({ datagramsUnrouted: 0 }); } finally { client.abort(); server.abort(); diff --git a/js/net/src/connection/stats.ts b/js/net/src/connection/stats.ts index 25fccbe8578..556860917d3 100644 --- a/js/net/src/connection/stats.ts +++ b/js/net/src/connection/stats.ts @@ -53,6 +53,13 @@ export interface Stats { /** Packets currently declared lost. The estimate can decrease when a loss proves spurious. */ packetsLost?: number; + + /** + * Datagrams received for a subscription with no datagram track: one this session doesn't + * know, has ended, or whose answer it hasn't received. Absent on a version without datagrams: + * moq-transport and moq-lite before 05. + */ + datagramsUnrouted?: number; } /** diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts index 562ff925d99..04a42fa4f34 100644 --- a/js/net/src/consume.test.ts +++ b/js/net/src/consume.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { Producer } from "./broadcast.ts"; import { BroadcastCache } from "./consume.ts"; import { StreamCode, StreamError } from "./error.ts"; -import { Route } from "./hop.ts"; +import { RouteInfo } from "./hop.ts"; import * as Instance from "./instance.ts"; import * as Path from "./path.ts"; @@ -13,16 +13,16 @@ test("cache identity follows the serving announcement, preserving held tracks", const broad = {}; const specific = {}; const instance = Instance.random(); - cache.announce(pool, broad, { ...Route.default, instance }); + cache.announce(pool, broad, { ...RouteInfo.default, instance }); const source = new Producer(); const track = source.createTrack("video"); const held = cache.insert(job, cache.instance(job), source.consume()); const ordered = held.track("video").subscribe().ordered(); - cache.announce(job, specific, { ...Route.default, instance }); + cache.announce(job, specific, { ...RouteInfo.default, instance }); const same = cache.get(job, cache.instance(job)); expect(same?.closed).toBe(held.closed); - cache.announce(job, specific, Route.default, true); + cache.announce(job, specific, RouteInfo.default, true); expect(cache.instance(job).route.instance).toBeUndefined(); expect(cache.get(job, cache.instance(job))).toBeUndefined(); const replacement = new Producer(); @@ -46,7 +46,7 @@ test("withdrawing one interest keeps another interest's explicit identity", () = const path = Path.from("room"); const first = {}; const second = {}; - const route = { ...Route.default, instance: Instance.random() }; + const route = { ...RouteInfo.default, instance: Instance.random() }; cache.announce(path, first, route); cache.announce(path, second, route); const source = new Producer(); @@ -70,7 +70,7 @@ test("an announcement lifecycle retires a blind cache without disturbing unrelat const held = cache.insert(path, cache.instance(path), source.consume()); const otherHeld = cache.insert(other, cache.instance(other), unrelated.consume()); const owner = {}; - cache.announce(Path.from("pool"), owner, Route.default); + cache.announce(Path.from("pool"), owner, RouteInfo.default); cache.withdraw(Path.from("pool"), owner); expect(cache.get(path, cache.instance(path))).toBeUndefined(); const same = cache.get(other, cache.instance(other)); @@ -87,10 +87,10 @@ test.each([false, true])("route alternation reuses every held instance (instance const broad = Path.from("pool"); const ownerA = {}; const ownerB = {}; - cache.announce(broad, ownerA, { ...Route.default, instance: explicit ? Instance.random() : undefined }); + cache.announce(broad, ownerA, { ...RouteInfo.default, instance: explicit ? Instance.random() : undefined }); const a = new Producer(); const heldA = cache.insert(path, cache.instance(path), a.consume()); - cache.announce(path, ownerB, { ...Route.default, instance: explicit ? Instance.random() : undefined }); + cache.announce(path, ownerB, { ...RouteInfo.default, instance: explicit ? Instance.random() : undefined }); const b = new Producer(); const heldB = cache.insert(path, cache.instance(path), b.consume()); cache.withdraw(path, ownerB); @@ -107,13 +107,13 @@ test("request validity cancels one selection period and permits the held fallbac const broad = Path.from("pool"); const a = {}; const b = {}; - cache.announce(broad, a, Route.default); + cache.announce(broad, a, RouteInfo.default); const sourceA = new Producer(); const instanceA = cache.instance(path); const heldA = cache.insert(path, instanceA, sourceA.consume()); const first = instanceA.validity?.signal; expect(first?.aborted).toBe(false); - cache.announce(path, b, Route.default); + cache.announce(path, b, RouteInfo.default); expect(first?.aborted).toBe(true); // A more specific route taking over ends the old instance's requests the way Rust does, so // players treat it as the instance going offline rather than an unexpected failure. @@ -142,16 +142,16 @@ test("request validity follows the selected interest and ignores unrelated withd const broad = {}; const first = {}; const second = {}; - cache.announce(Path.from("pool"), broad, Route.default); - cache.announce(path, first, Route.default); - cache.announce(path, second, Route.default); + cache.announce(Path.from("pool"), broad, RouteInfo.default); + cache.announce(path, first, RouteInfo.default); + cache.announce(path, second, RouteInfo.default); const source = new Producer(); const instance = cache.instance(path); const held = cache.insert(path, instance, source.consume()); const signal = instance.validity?.signal; cache.withdraw(Path.from("pool"), broad); cache.withdraw(path, second); - cache.announce(path, first, { ...Route.default, cost: 1n }); + cache.announce(path, first, { ...RouteInfo.default, cost: 1n }); expect(instance.validity?.signal).toBe(signal); expect(signal?.aborted).toBe(false); cache.withdraw(path, first); @@ -167,7 +167,7 @@ test("a blind selection retires on discovery while explicit instances need no pa const source = new Producer(); const held = cache.insert(path, blind, source.consume()); const owner = {}; - cache.announce(Path.from("pool"), owner, { ...Route.default, instance: Instance.random() }); + cache.announce(Path.from("pool"), owner, { ...RouteInfo.default, instance: Instance.random() }); expect(blind.validity?.signal.aborted).toBe(true); expect(cache.instance(path).validity).toBeUndefined(); cache.withdraw(Path.from("pool"), owner); diff --git a/js/net/src/consume.ts b/js/net/src/consume.ts index 3e19f1159dd..86b89c379c3 100644 --- a/js/net/src/consume.ts +++ b/js/net/src/consume.ts @@ -1,6 +1,6 @@ import type * as broadcast from "./broadcast.ts"; import { StreamCode, StreamError } from "./error.ts"; -import type { Route } from "./hop.ts"; +import type { RouteInfo } from "./hop.ts"; import * as Instance from "./instance.ts"; import { compareRouteCandidates, coveringPrefixes, Epochs } from "./internal.ts"; import type * as Path from "./path.ts"; @@ -52,13 +52,13 @@ export class BroadcastCache { Path.Valid, Map >(); - #announced = new Map>(); + #announced = new Map>(); #blind = new Map(); #blindPrefixes = new Map>(); #paths = new Map>(); /** Record this interest's live advertisement, preserving identity across route updates. */ - announce(path: Path.Valid, owner: object, route: Route, restart = false): void { + announce(path: Path.Valid, owner: object, route: RouteInfo, restart = false): void { // Only unannounced consumers covered by this advertisement lose their blind identity. for (const covered of this.#blindPrefixes.get(path) ?? []) this.#forgetBlind(covered); let entries = this.#announced.get(path); @@ -84,7 +84,7 @@ export class BroadcastCache { for (const prefix of coveringPrefixes(path)) { const entries = this.#announced.get(prefix); if (!entries) continue; - let best: { identity: object; route: Route } | undefined; + let best: { identity: object; route: RouteInfo } | undefined; const epochs = new Epochs([...entries.values()].map((entry) => entry.route)); for (const entry of entries.values()) { if (epochs.supersedes(entry.route)) continue; diff --git a/js/net/src/fill.test.ts b/js/net/src/fill.test.ts index bae555bf01f..ae66a7ea732 100644 --- a/js/net/src/fill.test.ts +++ b/js/net/src/fill.test.ts @@ -769,8 +769,7 @@ async function session(alpn: string) { connectSession({ url, transport: pair.client }), acceptSession({ transport: pair.server, url, publish: origin.consume() }), ]); - const publication = origin.createBroadcast(Path.from("archive")); - publication.announce(); + const publication = origin.route(Path.from("archive")); const broadcast = publication.broadcast; const track = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); // Groups 0 to 2 were published before this producer existed. diff --git a/js/net/src/goaway-requests.test.ts b/js/net/src/goaway-requests.test.ts index bc2df5b5235..fb869add6ca 100644 --- a/js/net/src/goaway-requests.test.ts +++ b/js/net/src/goaway-requests.test.ts @@ -107,9 +107,8 @@ async function handover(kind: "lite" | "ietf"): Promise { const consume = new OriginProducer(); const publish = new OriginProducer(); - const broadcastPublication = publish.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = publish.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const served: TrackProducer[] = []; let armVideo = true; @@ -299,9 +298,8 @@ async function routeDeath(kind: "lite" | "ietf", after: boolean): Promise const consume = new OriginProducer(); const publish = new OriginProducer(); - const broadcastPublication = publish.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = publish.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const served: TrackProducer[] = []; const serving = (async () => { diff --git a/js/net/src/group-id.test.ts b/js/net/src/group-id.test.ts index d025cecad53..e17c1de620d 100644 --- a/js/net/src/group-id.test.ts +++ b/js/net/src/group-id.test.ts @@ -86,12 +86,11 @@ for (const wire of WIRES) { ]); const path = Path.from("big"); - const publication = source.createBroadcast(path); - publication.announce(); + const publication = source.route(path); const datagrams = wire.datagrams ? publication.broadcast.createDatagrams("video") : undefined; const track = publication.broadcast.createTrack("video"); - const dynamic = relay.requestsRoute(path); + const dynamic = relay.routePrefix(path); const inputs: BroadcastConsumer[] = []; const forwarding = (async () => { for await (const request of dynamic.requested()) { diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index 2319ebcbfb8..d01492d599b 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -89,59 +89,48 @@ export const Cost = { }; /** - * The path a route took through the mesh and what using it costs. + * The stream a received route serves, the path it took through the mesh, and what using it + * costs: read-only facts on an announcement. Mirrors `RouteInfo` in rs/moq-net. * - * The metadata half of an advertisement: an origin `requests()` pairs it with the - * pattern it covers, a broadcast `announce()` with the broadcast's exact path, and - * an announce event carries it so consumers can read it back. + * Carried by an announce event. Applications never build one: an origin `Route` takes + * `RouteOptions`, and the origin and the relays fill in the rest. */ -export interface Route { +export interface RouteInfo { /** * The stream the route serves: routes with one instance serve one continuous sequence - * space, so a subscription resumes from one to another. Changing it on a standing - * advertisement is a restart. Always set on a route the origin delivers; given to an - * announcing handle, unset keeps the instance its route already has, a fresh random one - * minted when the handle was made. Mirrors `Route::instance` in rs/moq-net. - * - * Set one only for a publisher that continues its group sequences across restarts and - * raises {@link epoch} on each: readers stitch every route of one instance together, so - * restarting the sequences under an old instance splices unrelated groups into their tracks. - * Do not start a named instance with `~`: that marks one a relay minted (see - * {@link Instance.isMinted}), which signing refuses to bind and a catalog's instance check - * skips. The mark is not refused, since a relay forwarding a route keeps the one it received. + * space, so a subscription resumes from one to another. Always set on a route the origin + * delivers; pass it to an origin `request` to pin this stream. */ - instance?: Instance.Valid; + readonly instance?: Instance.Valid; /** * Which incarnation of {@link instance} this is: among its routes at one prefix only the - * highest epoch is eligible, cheaper or not. Never compared across instances. Ignored - * without an instance. + * highest epoch is eligible, cheaper or not. Never compared across instances. */ - epoch?: bigint; - /** The chain of hops the route has traversed, oldest first. */ - hops: Hop[]; + readonly epoch?: bigint; + /** + * The chain of hops the route has traversed, oldest first. A 0 entry is the anonymous mark; + * see {@link isAnonymous}. A route announced on this origin has none. + */ + readonly hops: readonly Hop[]; /** What pulling content via this route costs; lower wins. */ - cost: Cost; + readonly cost: Cost; /** * The token the request advertising this route carries, from `auth.add()`, or undefined * for the session's own credential. Only the session that issued it carries it, as the * token on its moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE * answering a solicited SUBSCRIBE_NAMESPACE, and moq-lite advertise the route unnamed. - * Swapping it renews the request with REQUEST_UPDATE. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. + * Swapping it renews the request with REQUEST_UPDATE. Local: a relay never forwards it. */ - auth?: Credential; + readonly auth?: Credential; } -/** Constructors and helpers for {@link Route}. */ -export const Route = { +/** Constructors and helpers for {@link RouteInfo}. @internal */ +export const RouteInfo = { /** An empty hop chain at zero cost: what a publisher seeds for a live broadcast. */ - default: { hops: [], cost: Cost.zero } as Route, + default: { hops: [], cost: Cost.zero } as RouteInfo, /** Fill route defaults and saturate the static price at the wire ceiling. */ - normalize( - route: - | Route - | { instance?: Instance.Valid; epoch?: bigint; hops?: readonly Hop[]; cost?: Cost; auth?: Credential } = {}, - ): Route { + normalize(route: Partial = {}): RouteInfo { const cost = route.cost ?? Cost.zero; if (typeof cost !== "bigint" || cost < 0n) throw new RangeError("route cost must be a non-negative bigint"); const max = 2n ** 62n - 1n; @@ -162,12 +151,12 @@ export const Route = { * An empty chain is a local announcement, not the anonymous mark. Ingress fills a * received empty list with the anonymous hop before yielding it. */ -export function isAnonymous(route: Route): boolean { +export function isAnonymous(route: RouteInfo): boolean { return route.hops.includes(UNKNOWN_HOP); } /** Whether two routes name the same instance, epoch, hop chain, cost, and auth. */ -export function routesEqual(a: Route | undefined, b: Route | undefined): boolean { +export function routesEqual(a: RouteInfo | undefined, b: RouteInfo | undefined): boolean { if (a === b) return true; if (!a || !b) return false; return ( diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts index 3060dcb9fbd..d920b04d8e8 100644 --- a/js/net/src/ietf/connection.test.ts +++ b/js/net/src/ietf/connection.test.ts @@ -162,7 +162,7 @@ test("SUBSCRIBE_NAMESPACE gets the published namespaces", async () => { const pair = createMockTransportPair(ALPN.DRAFT_18); const control = await Stream.open(pair.server, { version }); const origin = new OriginProducer(); - origin.createBroadcast(Path.from("room")).announce(); + origin.route(Path.from("room"), { hidden: true }).announce(); const connection = new Connection({ url: new URL("https://example.com"), quic: pair.server, @@ -397,9 +397,8 @@ for (const [version, alpn] of [[Version.DRAFT_22, ALPN.DRAFT_22]] as const) { const pair = createMockTransportPair(alpn); const control = await Stream.open(pair.server, { version }); const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("video", { timescale: Timescale.MILLI }); const connection = new Connection({ url: new URL("https://example.com"), @@ -433,9 +432,8 @@ async function serving(version: IetfVersion, alpn: string) { const pair = createMockTransportPair(alpn); const control = await Stream.open(pair.server, { version }); const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("video", { timescale: Timescale.MILLI }); const connection = new Connection({ url: new URL("https://example.com"), diff --git a/js/net/src/ietf/fetch.test.ts b/js/net/src/ietf/fetch.test.ts index d74b8f768c7..a34db56c948 100644 --- a/js/net/src/ietf/fetch.test.ts +++ b/js/net/src/ietf/fetch.test.ts @@ -53,8 +53,7 @@ function serve(version: IetfVersion, grant?: Signal, priority requiresSolicitation: false, grant, }); - const publication = origin.createBroadcast(Path.from("room")); - publication.announce(); + const publication = origin.route(Path.from("room")); const track = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI, priority, maxAge }); const uni = pair.client.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< ReadableStream @@ -487,8 +486,7 @@ test("a FETCH preserves relayed metadata and cached groups without upstream FETC for (const warm of [false, true]) { using fx = serve(version); const upstream = new OriginProducer(); - const publication = upstream.createBroadcast(Path.from("archive")); - publication.announce(); + const publication = upstream.route(Path.from("archive")); const track = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI }); writeGroup(track, 3n, ["3-0", "3-1"]); // lite-04 has no FETCH; lite-05 can fetch, but refuses empty subscription ranges. @@ -507,7 +505,7 @@ test("a FETCH preserves relayed metadata and cached groups without upstream FETC while (await group?.readFrame()) {} group?.close(); } - const requests = fx.origin.requestsRoute(Path.from("relayed")); + const requests = fx.origin.routePrefix(Path.from("relayed")); const cursor = requests.requested(); const requested = cursor.next(); const type = { ...range(version, at(3, 0), at(3, 0)), trackNamespace: Path.from("relayed") }; diff --git a/js/net/src/ietf/filter.test.ts b/js/net/src/ietf/filter.test.ts index 9280400cb86..be2a5d0dd1d 100644 --- a/js/net/src/ietf/filter.test.ts +++ b/js/net/src/ietf/filter.test.ts @@ -1,9 +1,10 @@ import { expect, test } from "bun:test"; import { Milli } from "@moq/clock"; import { ProtocolViolation } from "../error.ts"; -import { Reader } from "../stream.ts"; +import * as Path from "../path.ts"; +import { Reader, Writer } from "../stream.ts"; import * as Filter from "./filter.ts"; -import { joinFilter } from "./subscribe.ts"; +import { joinFilter, Subscribe } from "./subscribe.ts"; import { Version } from "./version.ts"; const OLD = Version.DRAFT_18; @@ -24,6 +25,45 @@ test("a live join is Next Object, its head left to a joining FETCH or a StartGro expect(draft22.fill).toEqual({ filter: { kind: "relative", groups: 1n }, rangeFilters: false }); }); +test.each([OLD, FIELDS, NEW])("a session start of group 0 joins live with no filter on %s", (version) => { + // Its earlier groups come from a range FETCH, so no draft spells an absolute {0, 0}. + expect(joinFilter(version, 0n)).toEqual({ filter: { kind: "unfiltered" } }); + expect(joinFilter(version, 3n)).toEqual({ filter: { kind: "absolute", startGroup: 3n, startObject: 0n } }); +}); + +async function subscribeBytes(filter: Filter.Filter, version: typeof OLD | typeof FIELDS | typeof NEW) { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ write: (chunk) => void chunks.push(chunk.slice()) }), + version, + ); + const msg = new Subscribe({ + requestId: 0n, + trackNamespace: Path.from("live"), + trackName: "video", + subscriberPriority: 128, + filter, + }); + await msg.encode(writer, version); + writer.close(); + await writer.closed; + const encoded = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const chunk of chunks) { + encoded.set(chunk, offset); + offset += chunk.length; + } + return encoded; +} + +test.each([OLD, FIELDS, NEW])("a filterless SUBSCRIBE omits LOCATION_FILTER on %s", async (version) => { + const filterless = await subscribeBytes({ kind: "unfiltered" }, version); + const next = await subscribeBytes({ kind: "nextObject" }, version); + expect(next.length).toBeGreaterThan(filterless.length); + const decoded = await Subscribe.decode(new Reader(undefined, filterless, version), version); + expect(decoded.filter).toEqual({ kind: "unfiltered" }); +}); + function roundTrip(filter: Filter.Filter, version: typeof OLD | typeof FIELDS | typeof NEW): Filter.Filter { return Filter.decode(Filter.encode(filter, version), version); } diff --git a/js/net/src/ietf/goaway.test.ts b/js/net/src/ietf/goaway.test.ts index 7ceb767ae72..480dddd5a32 100644 --- a/js/net/src/ietf/goaway.test.ts +++ b/js/net/src/ietf/goaway.test.ts @@ -235,7 +235,7 @@ for (const [version, alpn] of [ const control = await Stream.open(pair.server, { version }); await Stream.accept(pair.client, version); const origin = new OriginProducer(); - origin.createBroadcast(Path.from("room")).announce(); + origin.route(Path.from("room"), { hidden: true }).announce(); const connection = new Connection({ url: new URL("https://relay.example/"), quic: pair.server, @@ -265,7 +265,7 @@ for (const [version, alpn] of [ const control = await Stream.open(pair.server, { version }); await Stream.accept(pair.client, version); const origin = new OriginProducer(); - origin.createBroadcast(Path.from("room")).announce(); + origin.route(Path.from("room"), { hidden: true }).announce(); const connection = new Connection({ url: new URL("https://relay.example/"), quic: pair.server, diff --git a/js/net/src/ietf/join.test.ts b/js/net/src/ietf/join.test.ts index 38872a9ce63..04cd354f07c 100644 --- a/js/net/src/ietf/join.test.ts +++ b/js/net/src/ietf/join.test.ts @@ -58,14 +58,14 @@ async function nextStream( } /** A subscriber with one track, and the SUBSCRIBE it sent, still unanswered. */ -async function subscribed(version: IetfVersion = VERSION) { +async function subscribed(version: IetfVersion = VERSION, subscription: Partial = {}) { const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const subscriber = new Subscriber({ session }); const consumer = subscriber .consume(Path.from("room")) .track("video") - .subscribe({ ...track.Subscription.default(), ranges: [{ start: { relative: 0n } }] }); + .subscribe({ ...track.Subscription.default(), ranges: [{ start: { relative: 0n } }], ...subscription }); onTestFinished(() => { consumer.close(); session.close(); @@ -86,8 +86,11 @@ async function subscribed(version: IetfVersion = VERSION) { * A draft-18 subscriber with one track answered with Largest `largest`, and the joining * FETCH it sent in response, still unanswered. */ -async function join(largest: MessageLocation | null = { groupId: GROUP, objectId: 1n }) { - const subscription = await subscribed(); +async function join( + largest: MessageLocation | null = { groupId: GROUP, objectId: 1n }, + options: Partial = {}, +) { + const subscription = await subscribed(VERSION, options); await subscription.answer(largest); const fetching = await nextStream(subscription.client); @@ -264,6 +267,38 @@ for (const outcome of ["a refused joining FETCH", "an empty joining FETCH"] as c }); } +test("a reader in sequence order with no delay budget skips headless groups", async () => { + const { subscriber, consumer, fetching } = await join(undefined, { order: "ascending", delay: { max: null } }); + await refuse(fetching); + + // The joined group's head was refused, and the next group's stream starts mid-group. + await subscriber.handleGroup(...liveStream(GROUP, 2, ["t2"])); + await subscriber.handleGroup(...liveStream(GROUP + 1n, 1, ["m1"])); + await subscriber.handleGroup(...liveStream(GROUP + 2n, 0, ["n0"])); + + const group = await timeout(Nano.fromMilli(Milli(1000)), consumer.ordered().nextGroup()); + expect(group?.sequence).toBe(GROUP + 2n); +}); + +for (const version of VERSIONS) { + for (const objectId of [0n, 2n]) { + test(`draft ${version}: a group-0 reader skips the Largest group's cut tail after object ${objectId}`, async () => { + const { subscriber, consumer, answer } = await subscribed(version, { + order: "ascending", + delay: { max: null }, + ranges: [{ start: { absolute: 0n } }], + }); + await answer({ groupId: 0n, objectId }); + // Largest is inclusive, including object 0: its following tail is no whole message. + await subscriber.handleGroup(...liveStream(0n, Number(objectId) + 1, ["cut"], version)); + await subscriber.handleGroup(...liveStream(1n, 0, ["whole"], version)); + const group = await consumer.ordered().nextGroup(); + expect(group?.sequence).toBe(1n); + expect(await frames(group)).toEqual(["whole"]); + }); + } +} + test("a tail parked on a refused joining FETCH is dropped", async () => { const { subscriber, consumer, fetching } = await join(); @@ -589,8 +624,7 @@ async function publishing(version: IetfVersion) { server.abort(); }); - const publication = origin.createBroadcast(Path.from("room")); - publication.announce(); + const publication = origin.route(Path.from("room")); const broadcast = publication.broadcast; // Written once, before anyone subscribed, and left open: only the head can deliver it. const catalog = broadcast.createTrack("catalog.json", { timescale: Timescale.MILLI }); diff --git a/js/net/src/ietf/malformed.test.ts b/js/net/src/ietf/malformed.test.ts index 0382758948f..19bf77af8d2 100644 --- a/js/net/src/ietf/malformed.test.ts +++ b/js/net/src/ietf/malformed.test.ts @@ -57,9 +57,8 @@ async function serving(version: IetfVersion, alpn: string) { const pair = createMockTransportPair(alpn); const control = await Stream.open(pair.server, { version }); const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("video", { timescale: Timescale.MILLI }); const connection = new Connection({ url: new URL("https://example.com"), diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 5acaa91461a..256cee7b9f4 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -42,9 +42,8 @@ import { TrackStatusOk, TrackStatusRequest } from "./track.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; function publish(origin: OriginProducer, path: Path.Valid) { - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; return broadcast; } @@ -295,7 +294,7 @@ test("TRACK_STATUS finished at once is answered on every draft", async () => { const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const { pub, origin } = publisher(pair.server, { session }); - const requests = origin.requestsRoute(Path.from("room")).requested(); + const requests = origin.routePrefix(Path.from("room")).requested(); // The FIN arrives while the broadcast still resolves. const { stream, wire, finish } = requestStream(version); @@ -341,7 +340,7 @@ test("TRACK_STATUS outside the grant is refused on every draft", async () => { const session = new NativeSession(pair.server, version, true); const grant = new Signal(nothing()); const { pub, origin } = publisher(pair.server, { session, grant }); - const requests = origin.requestsRoute(Path.from("room")).requested(); + const requests = origin.routePrefix(Path.from("room")).requested(); const outside = requestStream(version); await pub.runTrackStatusRequest(trackStatus("room"), outside.stream); @@ -368,7 +367,7 @@ test("a second GOAWAY on an unanswered SUBSCRIBE closes the session", async () = const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const { pub, origin } = publisher(pair.server, { session }); - const requests = origin.requests(Path.from("room")).requested(); + const requests = origin.routePrefix(Path.from("room")).requested(); const held = requestStream(version); const task = pub.runSubscribe( @@ -426,7 +425,7 @@ test("a cancelled TRACK_STATUS releases its demand on every draft", async () => const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const { pub, origin } = publisher(pair.server, { session }); - const requests = origin.requestsRoute(Path.from("room")).requested(); + const requests = origin.routePrefix(Path.from("room")).requested(); // The broadcast is still resolving: its handler never answers. const early = requestStream(version); @@ -478,7 +477,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const { pub, origin } = publisher(pair.server, { session }); - const requests = origin.requestsRoute(Path.from("room")).requested(); + const requests = origin.routePrefix(Path.from("room")).requested(); // The broadcast is still resolving: its handler never answers. const early = requestStream(version); @@ -645,7 +644,7 @@ test("reserved namespace requests get DOES_NOT_EXIST while other dot namespaces // Navigation segments cannot be published. A covering application // route still proves these reserved requests are refused before lookup. const navigation = path.split("/")[0] === "."; - const claim = navigation ? origin.requests(Path.empty()) : undefined; + const claim = navigation ? origin.routePrefix(Path.empty()) : undefined; const requested = claim?.requested().next(); const broadcast = navigation ? undefined : publish(origin, Path.from(path)); const track = broadcast?.createTrack("video", { timescale: Timescale.MILLI }); @@ -1156,7 +1155,7 @@ test("a subscription outside a covering route's claim does not hear it", async ( const pair = createMockTransportPair(ALPN.DRAFT_18); const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("room/chat"))])); - const dynamic = chat.requestsRoute(Path.from("room")); + const dynamic = chat.routePrefix(Path.from("room")); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); @@ -1188,8 +1187,8 @@ test("a subscription hears the best covering route its claim allows", async () = const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); const scoped = (path: string) => origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from(path))])); - const chat = scoped("room/chat").requestsRoute(Path.from("room"), { cost: 1n }); - const video = scoped("room/video").requestsRoute(Path.from("room"), { cost: 5n }); + const chat = scoped("room/chat").routePrefix(Path.from("room"), { cost: 1n }); + const video = scoped("room/video").routePrefix(Path.from("room"), { cost: 5n }); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); @@ -1229,8 +1228,8 @@ test("a subscription hears a broader covering route when the narrowest cannot se }); const chat = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/chat"))])) - .requestsRoute(Path.from("tenant")); - const broad = origin.requestsRoute(Path.empty(), { cost: 5n }); + .routePrefix(Path.from("tenant")); + const broad = origin.routePrefix(Path.empty(), { cost: 5n }); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); @@ -1267,7 +1266,7 @@ test("a covering route's claim is compared relative to the served root", async ( requiresSolicitation: true, }); const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/room/chat"))])); - const dynamic = chat.requestsRoute(Path.from("tenant/room")); + const dynamic = chat.routePrefix(Path.from("tenant/room")); const subscription = await Stream.open(pair.client, { version: VERSION }); const accepted = await Stream.accept(pair.server, VERSION); @@ -1351,8 +1350,7 @@ test("a new instance at the same path clears a refusal", async () => { const pair = createMockTransportPair(ALPN.DRAFT_18); const { pub, origin } = publisher(pair.server); - const broadcast = origin.createBroadcast(Path.from("restarted")); - broadcast.announce({ instance: Instance.random() }); + const broadcast = origin.route(Path.from("restarted"), { instance: Instance.random() }); void pub.runPublishNamespaces(); @@ -1362,7 +1360,7 @@ test("a new instance at the same path clears a refusal", async () => { await declinePublishNamespace(declined, 0n); await sleep(Nano.fromMilli(Milli(SETTLE))); - broadcast.announce({ ...broadcast.route, instance: Instance.random() }); + wireOf(broadcast).update({ ...wireOf(broadcast).info, instance: Instance.random() }); const retried = await nextStream(pair.client); if (!retried) throw new Error("the new instance was never offered"); @@ -1563,18 +1561,18 @@ const OTHER: Hop = HopSchema.parse(5n); test("a price change on a held namespace is one REQUEST_UPDATE, not a withdrawal", async () => { const { clock, self, pub, origin, streams, close } = clustered(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); expect(await acceptClustered(stream)).toEqual({ hops: [VIA, self], cost: 4n }); - broadcast.announce({ hops: [VIA], cost: 8n }); + wireOf(broadcast).update({ hops: [VIA], cost: 8n }); expect(await readUpdate(stream)).toEqual({ hops: undefined, cost: 8n }); await acceptPublishNamespace(stream); - broadcast.announce({ hops: [VIA], cost: 0n }); + wireOf(broadcast).update({ hops: [VIA], cost: 0n }); expect(await readUpdate(stream)).toEqual({ hops: undefined, cost: 0n }); await acceptPublishNamespace(stream); @@ -1591,14 +1589,14 @@ test("a price change on a held namespace is one REQUEST_UPDATE, not a withdrawal test("a hop path change behind the same publisher is one REQUEST_UPDATE carrying HOP_PATH", async () => { const { clock, self, pub, origin, streams, close } = clustered(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); await acceptClustered(stream); - broadcast.announce({ hops: [VIA, MID], cost: 4n }); + wireOf(broadcast).update({ hops: [VIA, MID], cost: 4n }); expect(await readUpdate(stream)).toEqual({ hops: [VIA, MID, self], cost: undefined }); await acceptPublishNamespace(stream); @@ -1621,8 +1619,8 @@ test.each([ ] as const)("a solicited namespace takes %s as one NAMESPACE and no NAMESPACE_DONE", async (_, hops, cost) => { const { clock, self, pair, pub, origin, close } = clustered(true); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); const subscription = await Stream.open(pair.client, { version: VERSION }); const stream = await Stream.accept(pair.server, VERSION); @@ -1641,7 +1639,7 @@ test.each([ params: { cluster: { hops: [VIA, self], cost: 4n } }, }); - broadcast.announce({ hops: [...hops], cost }); + wireOf(broadcast).update({ hops: [...hops], cost }); expect(await entry()).toMatchObject({ suffix: Path.from("mine"), params: { cluster: { hops: [...hops, self], cost } }, @@ -1663,14 +1661,14 @@ test.each([ test("a first-hop change is one REQUEST_UPDATE carrying HOP_PATH, not a withdrawal", async () => { const { clock, self, pub, origin, streams, close } = clustered(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); await acceptClustered(stream); - broadcast.announce({ hops: [OTHER], cost: 4n }); + wireOf(broadcast).update({ hops: [OTHER], cost: 4n }); expect(await readUpdate(stream)).toEqual({ hops: [OTHER, self], cost: undefined }); await acceptPublishNamespace(stream); @@ -1685,8 +1683,8 @@ test("a first-hop change is one REQUEST_UPDATE carrying HOP_PATH, not a withdraw /** A different broadcast is not an update: it withdraws the old one and advertises again. */ function republish(origin: OriginProducer, old: BroadcastProducer) { - const next = origin.createBroadcast(Path.from("mine")); - next.announce({ hops: [VIA], cost: 4n }); + const next = origin.route(Path.from("mine"), { hidden: true }); + wireOf(next).update({ hops: [VIA], cost: 4n }); old.close(); } @@ -1750,15 +1748,15 @@ test("swapping a route's token is one REQUEST_UPDATE carrying it", async () => { const { clock, pub, origin, streams, close } = clustered(false, tokens); const { sent, restore } = carriedTokens(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n, auth: tokens.issue("first") }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n, auth: tokens.issue("first") }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); await acceptClustered(stream); expect(sent).toEqual(["first"]); - broadcast.announce({ hops: [VIA], cost: 4n, auth: tokens.issue("second") }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n, auth: tokens.issue("second") }); expect(await readUpdate(stream)).toEqual({ hops: undefined, cost: undefined }); await acceptPublishNamespace(stream); expect(sent).toEqual(["first", "second"]); @@ -1775,13 +1773,13 @@ test("a foreign route token is not carried", async () => { const { clock, pub, origin, streams, close } = clustered(false, new Tokens()); const { sent, restore } = carriedTokens(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n, auth: foreign.issue("first") }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n, auth: foreign.issue("first") }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); await acceptClustered(stream); - broadcast.announce({ hops: [VIA], cost: 4n, auth: foreign.issue("second") }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n, auth: foreign.issue("second") }); const more = watch(stream.reader.u53()); await clock.advance(Nano.zero); expect(more.state).toBe("pending"); @@ -1798,13 +1796,13 @@ test("naming a token on an unnamed request makes it again", async () => { const { clock, pub, origin, streams, close } = clustered(false, tokens); const { sent, restore } = carriedTokens(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const old = await take(clock, streams); await acceptClustered(old); - broadcast.announce({ hops: [VIA], cost: 4n, auth: tokens.issue("first") }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n, auth: tokens.issue("first") }); expect(await old.reader.done().catch(error)).toMatchObject({ code: StreamCode.Cancel }); await acceptClustered(await take(clock, streams)); @@ -1821,9 +1819,9 @@ test("closing a route's token makes its request again without it", async () => { const { clock, pub, origin, streams, close } = clustered(false, tokens); const { sent, restore } = carriedTokens(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); const token = tokens.issue("first"); - broadcast.announce({ hops: [VIA], cost: 4n, auth: token }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n, auth: token }); void pub.runPublishNamespaces(); const old = await take(clock, streams); @@ -1842,9 +1840,9 @@ test("closing a route's token makes its request again without it", async () => { test("a republish withdraws the PUBLISH_NAMESPACE and advertises again", async () => { const { clock, pub, origin, streams, close } = clustered(); try { - const broadcastPublication = origin.createBroadcast(Path.from("mine")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce({ hops: [VIA], cost: 4n }); + const broadcastRoute = origin.route(Path.from("mine"), { hidden: true }); + const broadcast = broadcastRoute.broadcast; + wireOf(broadcastRoute).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const old = await take(clock, streams); @@ -1864,9 +1862,9 @@ test("a republish withdraws the PUBLISH_NAMESPACE and advertises again", async ( test("a republish withdraws the solicited NAMESPACE and sends it again", async () => { const { pair, pub, origin, close } = clustered(true); try { - const broadcastPublication = origin.createBroadcast(Path.from("mine")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce({ hops: [VIA], cost: 4n }); + const broadcastRoute = origin.route(Path.from("mine"), { hidden: true }); + const broadcast = broadcastRoute.broadcast; + wireOf(broadcastRoute).update({ hops: [VIA], cost: 4n }); const subscription = await Stream.open(pair.client, { version: VERSION }); const stream = await Stream.accept(pair.server, VERSION); @@ -1903,14 +1901,14 @@ test.each([ ])("a refused update %s", async (_, retryInterval) => { const { clock, pub, origin, streams, close } = clustered(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); await acceptClustered(stream); - broadcast.announce({ hops: [VIA], cost: 8n }); + wireOf(broadcast).update({ hops: [VIA], cost: 8n }); await readUpdate(stream); await declinePublishNamespace(stream, retryInterval); @@ -1944,8 +1942,8 @@ test.each([ test("a refused update whose peer closes the request keeps the loop running", async () => { const { clock, pub, origin, streams, close } = clustered(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); let failed: unknown; void pub.runPublishNamespaces().catch((err: unknown) => { failed = err; @@ -1954,7 +1952,7 @@ test("a refused update whose peer closes the request keeps the loop running", as const stream = await take(clock, streams); await acceptClustered(stream); - broadcast.announce({ hops: [VIA], cost: 8n }); + wireOf(broadcast).update({ hops: [VIA], cost: 8n }); await readUpdate(stream); await declinePublishNamespace(stream, 1n); stream.close(); @@ -1975,17 +1973,17 @@ test("a refused update whose peer closes the request keeps the loop running", as test("a change while an update is unanswered waits for its answer", async () => { const { clock, pub, origin, streams, close } = clustered(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); await acceptClustered(stream); - broadcast.announce({ hops: [VIA], cost: 8n }); + wireOf(broadcast).update({ hops: [VIA], cost: 8n }); expect(await readUpdate(stream)).toEqual({ hops: undefined, cost: 8n }); - broadcast.announce({ hops: [VIA], cost: 12n }); + wireOf(broadcast).update({ hops: [VIA], cost: 12n }); const second = watch(stream.reader.u53()); await clock.advance(Nano.fromMilli(Milli(1_000))); expect(second.state).toBe("pending"); @@ -2013,7 +2011,7 @@ test("a change while an update is unanswered waits for its answer", async () => test("an advertisement the peer keeps ending comes back on a growing backoff", async () => { const { clock, pub, origin, streams, close } = clustered(); try { - origin.createBroadcast(Path.from("mine")).announce({ hops: [VIA], cost: 4n }); + wireOf(origin.route(Path.from("mine"), { hidden: true })).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); let stream = await take(clock, streams); @@ -2041,14 +2039,14 @@ test("an advertisement the peer keeps ending comes back on a growing backoff", a test("an unanswered update drops the request and re-offers the namespace fresh", async () => { const { clock, pub, origin, streams, close } = clustered(); try { - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ hops: [VIA], cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { hidden: true }); + wireOf(broadcast).update({ hops: [VIA], cost: 4n }); void pub.runPublishNamespaces(); const stream = await take(clock, streams); await acceptClustered(stream); - broadcast.announce({ hops: [VIA], cost: 8n }); + wireOf(broadcast).update({ hops: [VIA], cost: 8n }); await readUpdate(stream); const reset = watch(stream.reader.u53()); @@ -2079,8 +2077,7 @@ test.each([ const self: Hop = HopSchema.parse(7n); const pair = createMockTransportPair(ALPN.DRAFT_18); const { pub, origin } = publisher(pair.server, { cluster: { self, peer } }); - const broadcast = origin.createBroadcast(Path.from("mine")); - broadcast.announce({ cost: 4n }); + const broadcast = origin.route(Path.from("mine"), { cost: 4n }); void pub.runPublishNamespaces(); const stream = await nextStream(pair.client); @@ -2090,7 +2087,7 @@ test.each([ expect(msg.trackNamespace).toBe(Path.from("mine")); await acceptPublishNamespace(stream); - broadcast.announce({ cost: peer === undefined ? 8n : 4n }); + broadcast.setCost(peer === undefined ? 8n : 4n); await noStream(pair.client, clock); origin.close(); @@ -2447,6 +2444,51 @@ test("draft-22: an absolute subscribe is no join", async () => { } }); +/** + * A filterless SUBSCRIBE without a fill joins the latest group, as a relay cache always served + * one; only an absolute start from group 0 replays the groups before it. Ascending order sends + * history first, so the first stream tells them apart. + */ +test.each(IETF_VERSIONS)("version %d: only an absolute start replays history", async (version) => { + // Bounded, since draft 20 spells an open {0, 0} as no filter at all. + const zero: Filter.Filter = { kind: "absolute", startGroup: 0n, startObject: 0n, endGroup: 1n }; + for (const [filter, expected] of [ + [{ kind: "unfiltered" }, [1n]], + [zero, [0n, 1n]], + ] as const) { + const fx = fixture(version); + // Unlimited max age, so the first group is still served once its successor exists. + const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); + writeGroup(track, 1); + writeGroup(track, 1); + + const { client } = await runSubscribe( + fx, + new Subscribe({ + requestId: 7n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + groupOrder: "ascending", + filter, + }), + ); + + try { + const served: bigint[] = []; + for (const _ of expected) { + const stream = await nextUni(fx.uni); + if (!stream) throw new Error(`${version} ${filter.kind}: missing group stream`); + served.push((await GroupMessage.decode(new Reader(stream, undefined, version), version)).groupId); + } + expect(served).toEqual([...expected]); + } finally { + fx.close(); + client.close(); + } + } +}); + /** * The draft's own current-group join: a Next Object subscription for the live tail, plus a * StartGroup=1 fill for the head already published. The two must meet exactly, so the head @@ -2621,8 +2663,8 @@ test("draft-22: a backwards range within one group serves nothing and ends the s } }); -// Flaky in the workspace suite: the live group sometimes resets while read. Isolate its -// clock and stream ordering before re-enabling. +// Quarantined: the live group intermittently resets while read. +// Investigation: quest/m0/test-flakes-2/ietf-wider-fill.md. test.skip.each(["ascending", "descending"] as const)("draft-22: a wider fill is refused in %s order", async (order) => { const fx = fixture(); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 55034c0c5c3..7bb17df7053 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -19,7 +19,7 @@ import { unauthorized, } from "../error.ts"; import type * as group from "../group.ts"; -import type { Hop, Route } from "../hop.ts"; +import type { Hop, RouteInfo } from "../hop.ts"; import { finalSequence, firstEligible, hiddenBelow, hooks, presented, type ReadGroupFrame } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; @@ -79,7 +79,7 @@ const RETRY_BASE = 100; const RETRY_MAX = 5000; /** Cluster parameters for one snapshot: the advertised hops, then our own id last. */ -function clusterFor(base: Cluster.Advert | undefined, route: Route): Cluster.Advert | undefined { +function clusterFor(base: Cluster.Advert | undefined, route: RouteInfo): Cluster.Advert | undefined { if (!base) return undefined; const self = base.hops.at(-1); if (self === undefined) return undefined; @@ -485,7 +485,7 @@ export class Publisher { const subscribe = (source: broadcast.Consumer) => { // A range that resolves without the live edge has a fixed start, which is no join: // bound the subscription before subscribing so the refresh ratio never judges it. - const fixed = subscribeRange(msg, {}, version); + const fixed = filterRange(msg.filter, {}); // Parked past a bounded window's end: the peer reads this stream's end as the track's. return hooks.parked( source.track(msg.trackName).subscribe({ @@ -685,7 +685,7 @@ export class Publisher { msg.fill && msg.forward && Filter.isDraft20(version) ? fillRange(msg.fill, msg.filter, edge.largest) : undefined; - const requested = subscribeRange(msg, edge, version); + const requested = filterRange(msg.filter, edge); // A copy, since an update moves the cursor in place and must not move the bounds too. const range = { ...afterFill(requested, fill, edge) }; // What the subscriber asked for, which only a narrowing changes: the fill's split @@ -1819,7 +1819,7 @@ export class Publisher { /** * The extension parameters an advertisement of `route` carries. */ - #params(route: Route): AdvertParams { + #params(route: RouteInfo): AdvertParams { return { cluster: clusterFor(this.#advert, route) }; } @@ -1828,7 +1828,7 @@ export class Publisher { * is logged and skipped: a peer that wants none of this rejects each one and stays * connected. */ - async #advertise(path: Path.Valid, requests: Requests, route: Route): Promise { + async #advertise(path: Path.Valid, requests: Requests, route: RouteInfo): Promise { const params = this.#params(route); const auth = this.#carry(route.auth); const requestId = await this.#session.nextRequestId(); @@ -1840,7 +1840,7 @@ export class Publisher { // leaving this one namespace unadvertised. let request: RequestStream | undefined; try { - const deadline = new Deadline(); + const deadline = new Deadline(Milli(ADVERTISE_TIMEOUT_MS)); const answer = await deadline.wait( (async (): Promise => { const opened = await RequestStream.open(this.#session, deadline, { quic: this.#quic }); @@ -2869,31 +2869,13 @@ function liveEdge(track: TrackSubscriber): LiveEdge { }; } -/** - * Resolve a SUBSCRIBE's Location Filter into the range to serve. - * - * Draft-22 is honored whole. Earlier drafts honor only Largest Object, which a joining - * FETCH pairs with to recover the group's head: their Filter Type tag has absolute forms - * we never served, and starting to interpret them now would change what an existing peer - * receives. - */ -function subscribeRange(msg: Subscribe, edge: LiveEdge, version: IetfVersion): ServeRange { - if (!Filter.isDraft20(version) && msg.filter.kind !== "nextObject") { - if (msg.filter.kind !== "unfiltered") { - console.warn(`filter not supported before draft-22, ignoring: ${msg.filter.kind}`); - } - return {}; - } - - return filterRange(msg.filter, edge); -} - /** The Locations a single Location Filter selects, resolved against the live edge. */ function filterRange(filter: Filter.Filter, edge: LiveEdge): ServeRange { switch (filter.kind) { // No restriction. moq-lite starts at the beginning of the latest group, which is the // join point it is built around; a subscription passes objects as they are published, - // so an absent filter is not a request to replay history. + // so an absent filter is not a request to replay history. A subscriber that wants the + // track from its start fetches the groups before the live one. case "unfiltered": return {}; // `{Largest.Group, Largest.Object + 1}`. Everything below it, including the already diff --git a/js/net/src/ietf/request_stream.test.ts b/js/net/src/ietf/request_stream.test.ts index ed9a9a486b6..aab85a684cb 100644 --- a/js/net/src/ietf/request_stream.test.ts +++ b/js/net/src/ietf/request_stream.test.ts @@ -178,7 +178,7 @@ test("a request's deadline ends it through the request, so later ends do nothing }); const aborts = spyOn(stream, "abort"); const session = { version: Version.DRAFT_18, client: true, openBi: () => stream } as unknown as Session; - const deadline = new Deadline(); + const deadline = new Deadline(Milli(REQUEST_TIMEOUT_MS)); const request = await RequestStream.open(session, deadline); const answer = deadline.wait(new Promise(() => {})).catch((e: unknown) => e); await clock.advance(Nano.fromMilli(Milli(REQUEST_TIMEOUT_MS))); @@ -213,7 +213,7 @@ test("a request waiting for stream credit outlasts its deadline, which runs from client: true, openBi: () => opening.promise, } as unknown as Session; - const deadline = new Deadline(); + const deadline = new Deadline(Milli(REQUEST_TIMEOUT_MS)); const send = mock((request: RequestStream) => request.writer.u8(1)); const sending = RequestStream.open(session, deadline).then(send); let failure: unknown; diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts index 41984268004..1e619f8be90 100644 --- a/js/net/src/ietf/subscribe.ts +++ b/js/net/src/ietf/subscribe.ts @@ -45,16 +45,25 @@ function decodeGroupOrder(value: number): Order { } /** - * How this implementation joins a live track: at the current group, from its first object, - * as the Rust subscriber does. + * Join at a known absolute start, or at the current group's first object as Rust does. * - * The subscription itself starts at the next object (draft 18's Largest Object), and the + * A live subscription starts at the next object (draft 18's Largest Object), and the * group's head comes on a fetch stream. Draft 22 asks for it with a fill one group back from * the next, its current-group join (section 3.5); earlier drafts have no fill, so the * subscriber sends a Relative Joining FETCH once SUBSCRIBE_OK names Largest. Spelling the * filter keeps a peer from reading an absent one its own way. + * + * A start of group 0 is filterless on every draft, which our publishers serve from the start + * of the latest group; the copy's fills fetch the groups below it as one range FETCH. Draft 20 + * has no absolute {0, 0} distinct from no filter, so no draft spells one. */ -export function joinFilter(version: IetfVersion): { filter: Filter.Filter; fill?: Filter.Fill } { +export function joinFilter(version: IetfVersion, start?: bigint): { filter: Filter.Filter; fill?: Filter.Fill } { + if (start === 0n) return { filter: { kind: "unfiltered" } }; + // Open-ended, since REQUEST_UPDATE carries no range here: a finite end would hold the + // subscription short when another reader later widens the track's demand. + if (start !== undefined) { + return { filter: { kind: "absolute", startGroup: start, startObject: 0n } }; + } const filter: Filter.Filter = { kind: "nextObject" }; if (!Filter.isDraft20(version)) return { filter }; return { filter, fill: { filter: { kind: "relative", groups: 1n }, rangeFilters: false } }; @@ -141,7 +150,10 @@ export class Subscribe { params.maxAge = this.maxAge; params.groupOrder = encodeGroupOrder(this.groupOrder); params.forward = this.forward; - params.subscriptionFilter = this.filter; + // Unfiltered is the absent parameter, which a publisher reads as a live join. + if (this.filter.kind !== "unfiltered") { + params.subscriptionFilter = this.filter; + } // FILL_PARAMETERS and INCLUDE_PROPERTIES arrived in draft-22. An older peer reads // either as an unknown parameter, which is a protocol violation, so they are diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index c7d99a205f9..4da2efa57b8 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -14,6 +14,7 @@ import * as track from "../track.ts"; import * as demandUtil from "../util/demand.ts"; import type * as Cluster from "./cluster.ts"; import { Connection } from "./connection.ts"; +import { Fetch } from "./fetch.ts"; import { type GroupFlags, Group as GroupMessage } from "./object.ts"; import { Parameters } from "./parameters.ts"; import type { Properties } from "./properties.ts"; @@ -971,7 +972,7 @@ test("SUBSCRIBE_OK's largest is known before any group arrives", async () => { track.close(); }); -test.each(["acceptance", "timeout"])("returning demand preserves pending setup until %s", async (outcome) => { +test.each(["acceptance", "refusal"])("returning demand preserves pending setup until %s", async (outcome) => { const clock = new Manual(); const installed = clock.install(); const pair = createMockTransportPair(ALPN.DRAFT_18); @@ -1045,9 +1046,13 @@ test.each(["acceptance", "timeout"])("returning demand preserves pending setup u expect(await info).toMatchObject({ priority: 127 - 128 }); expect(returned.closed.peek()).toBeUndefined(); } else { - await clock.advance(Nano.fromMilli(Milli(4_000))); + // No answer budget: an hour later the same setup still waits for the publisher. + await clock.advance(Nano.fromMilli(Milli(3_600_000))); + expect(returned.closed.peek()).toBeUndefined(); + await peer.writer.u53(RequestError.id); + await new RequestError({ errorCode: 0x10, reasonPhrase: "not here" }).encode(peer.writer, VERSION); expect(await info).toBeInstanceOf(Error); - expect(((await info) as Error).message).toContain("request timed out after 10000ms"); + expect(((await info) as Error).message).toContain("not here"); } expect(opening).toHaveBeenCalledTimes(1); }); @@ -1153,6 +1158,166 @@ test("early group waits for SUBSCRIBE_OK priority before track acceptance", asyn track.close(); }); +test.each([Version.DRAFT_18, Version.DRAFT_20, Version.DRAFT_22])( + "an absolute prefix survives an answer behind its first burst (%s)", + async (version) => { + const pair = createMockTransportPair(ALPN.DRAFT_18); + const subscriber = new Subscriber({ session: new NativeSession(pair.server, version, true) }); + const consumer = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ + ...track.Subscription.default(), + ranges: [{ start: { absolute: 1n } }], + order: "ascending", + delay: { max: null }, + }); + const peer = await nextStream(pair.client, version); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + try { + expect(await peer.reader.u53()).toBe(Subscribe.id); + expect((await Subscribe.decode(peer.reader, version)).filter).toEqual({ + kind: "absolute", + startGroup: 1n, + startObject: 0n, + }); + const arrivals = [1n, 2n].map((groupId) => + subscriber.handleGroup( + new GroupMessage({ + trackAlias: ALIAS, + groupId, + subGroupId: 0n, + publisherPriority: 0, + flags: groupFlags(true), + }), + new Reader(undefined, encodeObjects([0, 0]), version), + ), + ); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ trackAlias: ALIAS, largest: { groupId: 2n, objectId: 1n } }).encode( + peer.writer, + version, + ); + await Promise.all(arrivals); + const ordered = consumer.ordered(); + try { + for (const sequence of [1n, 2n]) { + const group = await ordered.nextGroup(); + expect(group?.sequence).toBe(sequence); + if (!group) throw new Error("missing prefix group"); + expect(await group.readString()).toBe("object 0"); + expect(await group.readString()).toBe("object 0"); + expect(await group.readString()).toBeUndefined(); + } + } finally { + ordered.close(); + } + } finally { + peer.close(); + consumer.close(); + } + }, +); + +test.each([Version.DRAFT_18, Version.DRAFT_20, Version.DRAFT_22])( + "a start of group 0 joins live with no filter and fetches the groups below Largest (%s)", + async (version) => { + const pair = createMockTransportPair(ALPN.DRAFT_18); + const subscriber = new Subscriber({ session: new NativeSession(pair.server, version, true) }); + const consumer = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ + ...track.Subscription.default(), + ranges: [{ start: { absolute: 0n } }], + order: "ascending", + delay: { max: null }, + }); + const peer = await nextStream(pair.client, version); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + const ordered = consumer.ordered(); + let fetch: Awaited> | undefined; + try { + expect(await peer.reader.u53()).toBe(Subscribe.id); + const msg = await Subscribe.decode(peer.reader, version); + expect(msg.filter).toEqual({ kind: "unfiltered" }); + expect(msg.fill).toBeUndefined(); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ trackAlias: ALIAS, largest: { groupId: 2n, objectId: 1n } }).encode( + peer.writer, + version, + ); + // Our publisher serves the filterless join from the start of the latest group. + await subscriber.handleGroup( + new GroupMessage({ + trackAlias: ALIAS, + groupId: 2n, + subGroupId: 0n, + publisherPriority: 0, + flags: groupFlags(true), + }), + new Reader(undefined, encodeObjects([0, 0]), version), + ); + // The reader waits on group 0, which only a FETCH can bring. + void ordered.nextGroup().catch(() => {}); + fetch = await nextStream(pair.client, version); + if (!fetch) throw new Error("missing FETCH stream"); + expect(await fetch.reader.u53()).toBe(Fetch.id); + const { type } = await Fetch.decode(fetch.reader, version); + // One range for groups 0 and 1, below the live group 2. + if (type.kind === "standalone") { + expect(version).toBe(Version.DRAFT_18); + expect([type.start, type.end.group]).toEqual([{ group: 0n, object: 0n }, 1n]); + } else { + expect(type).toMatchObject({ + kind: "filtered", + filter: { kind: "absolute", startGroup: 0n, startObject: 0n, endGroup: 1n }, + }); + } + } finally { + ordered.close(); + fetch?.close(); + peer.close(); + consumer.close(); + } + }, +); + +// SUBSCRIBE_UPDATE carries no range here, so a finite end would hold the upstream copy short +// once another reader widens the track's demand. +test("a bounded subscription asks upstream from its start without an end", async () => { + const version = Version.DRAFT_22; + const pair = createMockTransportPair(ALPN.DRAFT_18); + const subscriber = new Subscriber({ session: new NativeSession(pair.server, version, true) }); + const consumer = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ + ...track.Subscription.default(), + ranges: [{ start: { absolute: 4n }, end: { absolute: 10n } }], + order: "ascending", + delay: { max: null }, + }); + // The range reaches past the live edge TRACK_STATUS names, so it subscribes. + const status = await nextStream(pair.client, version); + if (!status) throw new Error("missing TRACK_STATUS stream"); + expect(await status.reader.u53()).toBe(TrackStatusRequest.id); + await TrackStatusRequest.decode(status.reader, version); + await status.writer.u53(TrackStatusOk.id); + await new TrackStatusOk({ largest: { groupId: 5n, objectId: 0n } }).encode(status.writer, version); + const peer = await nextStream(pair.client, version); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + try { + expect(await peer.reader.u53()).toBe(Subscribe.id); + const { filter } = await Subscribe.decode(peer.reader, version); + expect(filter).toEqual({ kind: "absolute", startGroup: 4n, startObject: 0n, endGroup: undefined }); + } finally { + peer.close(); + status.close(); + consumer.close(); + } +}); + /** * A group is the unit an application resyncs on, so one served from partway through is * unusable: the objects on the stream do not decode without the head the filter excluded, @@ -1232,6 +1397,41 @@ test("an END_OF_GROUP status on a marked stream finishes the group", async () => track.close(); }); +/** + * Only the stream's end may follow an END_OF_GROUP or END_OF_TRACK status: an object past + * the final one makes the track malformed, rather than ending it or its group clean. + * Matches the Rust rule. + */ +test.each([ + ["END_OF_GROUP", [0], 0x3], + ["END_OF_TRACK", [0], 0x4], + ["a standalone END_OF_TRACK", [], 0x4], +])("an object after %s is malformed", async (_, objects, status) => { + const { subscriber, track } = await subscribeTrack(); + + // The objects, the zero-length status object, then one more object. + const bytes = new Uint8Array([...encodeObjects(objects), 0, 0, status, ...encodeObjects([0])]); + const reader = new Reader(undefined, bytes, VERSION); + const stop = spyOn(reader, "stop"); + await subscriber.handleGroup( + new GroupMessage({ + trackAlias: ALIAS, + groupId: 3n, + subGroupId: 0n, + publisherPriority: 0, + flags: groupFlags(true), + }), + reader, + ); + + expect(stop).toHaveBeenCalledTimes(1); + const err = stop.mock.calls[0][0]; + expect(err).toBeInstanceOf(StreamError); + expect((err as StreamError).code).toBe(StreamCode.MalformedTrack); + expect(track.closed.peek()).toBe(err as StreamError); + stop.mockRestore(); +}); + /** * A clear FIRST_OBJECT whose first ID is not 0 still has a hole at the front, so the * stream is dropped and the track resumes at the next group. diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index a97fbc8d720..7dd400a79e4 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -21,7 +21,7 @@ import { } from "../error.ts"; import { type Fill, fillChanged, type Requests, wantedRuns } from "../fill.ts"; import type * as netGroup from "../group.ts"; -import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; +import { Cost, type RouteInfo, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import * as Instance from "../instance.ts"; import { finalSequence, hiddenBelow, hooks, maxDelayOf, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import type { Lister } from "../listing.ts"; @@ -34,7 +34,7 @@ import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; import { closeUnwanted } from "../util/announce.ts"; import { Deadline } from "../util/deadline.ts"; -import { abandoned as demandAbandoned, isAbandoned, unwanted } from "../util/demand.ts"; +import { abandoned as demandAbandoned, isAbandoned, unrequested, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; import { min } from "../util/sequence.ts"; import { bounded, wireSpan } from "../window.ts"; @@ -94,8 +94,8 @@ type Subscription = { }; // Out-parameter for #openSubscribe: lets the caller observe partial progress -// (stream opened, trackAlias registered) so it can clean up on timeout even -// before the setup promise settles. +// (stream opened, trackAlias registered) so it can clean up a failed or abandoned setup +// even before the setup promise settles. type SubscribeSetupState = { stream?: RequestStream; // The accepted subscription, which a registered alias names. @@ -104,7 +104,7 @@ type SubscribeSetupState = { requests?: Requests; /** * Aborts once the caller has given up. An open still waiting leaves the line, and setup - * checks it once the stream exists so a request opened after the timeout still unwinds: + * checks it once the stream exists so a request opened after cancellation still unwinds: * nothing else settles setup if the peer has gone quiet, and the cleanup that fired first * saw no stream to tear down. */ @@ -119,7 +119,7 @@ type SubscribeSetupState = { }; /** An announced path: each live advertisement referencing it, oldest first, and the route it holds. */ -type Announced = { adverts: { id: number; route: Route }[]; route: Route }; +type Announced = { adverts: { id: number; route: RouteInfo }[]; route: RouteInfo }; /** A local announce reader's filter: its scope, the prefix it asked for, and its hidden opt-in. */ type Filter = { scope: Path.Pattern; prefix: Path.Valid; hidden: boolean; covering: boolean }; @@ -266,7 +266,7 @@ export class Subscriber { } // What a route costs once the peer has asked us to leave. - #priced(route: Route): Route { + #priced(route: RouteInfo): RouteInfo { if (!this.#goingAway()) return route; if (route.cost === DRAIN_COST) return route; return { ...route, cost: DRAIN_COST }; @@ -300,7 +300,7 @@ export class Subscriber { /** * The route an advertisement carries, before it is minted an instance; one without a path is anonymous and free. */ - #route({ cluster }: AdvertParams): Route { + #route({ cluster }: AdvertParams): RouteInfo { return cluster === undefined ? { hops: [UNKNOWN_HOP], cost: Cost.zero } : { ...cluster }; } @@ -370,7 +370,7 @@ export class Subscriber { * Record one more advertisement for a path, returning the id that names it. Consumers * hear `start` for the first; a later one stands as the newest (see `#settleAnnounce`). */ - #attachAnnounce(path: Path.Valid, route: Route): number { + #attachAnnounce(path: Path.Valid, route: RouteInfo): number { const id = this.#nextAdvert++; const existing = this.#announced.get(path); if (existing) { @@ -396,7 +396,7 @@ export class Subscriber { } /** Replace the route of advertisement `id`, which moves the path only while it is the newest. */ - #updateAnnounce(path: Path.Valid, id: number, route: Route) { + #updateAnnounce(path: Path.Valid, id: number, route: RouteInfo) { const existing = this.#announced.get(path); const advert = existing?.adverts.find((advert) => advert.id === id); if (existing === undefined || advert === undefined) return; @@ -786,9 +786,9 @@ export class Subscriber { // Keep the request pending until SUBSCRIBE_OK supplies immutable track metadata. // Group streams already wait on the alias, so early data stays behind this response. - // Open the stream and wait for SUBSCRIBE_OK under a timeout. State - // flows back via `state` so the timeout path can clean up the stream - // and any registration if setup eventually finishes. + // Wait for SUBSCRIBE_OK while the track is wanted, since the publisher may hold its + // answer. State lets cancellation clean up streams and registration even if setup + // finishes later. const state: SubscribeSetupState = { cancel: new AbortController() }; const deadline = new Deadline(); const setup = this.#openSubscribe(state, broadcast, request, requestId, deadline, known?.producer); @@ -810,7 +810,7 @@ export class Subscriber { let maxAge: number | undefined; const abandoned = new Error("subscribe abandoned before it was accepted"); try { - // Returning demand keeps the same setup and its original deadline. + // Returning demand keeps the same setup. const accepted = deadline.wait(setup); for (;;) { const result = await untilAborted(race([accepted, waitAbandoned()]), valid); @@ -838,8 +838,7 @@ export class Subscriber { ); // Runs now for whatever is already open, and again if `setup` settles late. // Retirement is repeated because a late SUBSCRIBE_OK can register an alias after - // the first pass. The request ends once: a deadline failure already reset it through - // the same stream, so a second end here does nothing. + // the first pass. The request ends once, so repeated cleanup does nothing. const cleanup = () => { state.cancel.abort(e); @@ -865,8 +864,7 @@ export class Subscriber { // Tear down what is already open rather than waiting on `setup`. A peer that // opened the stream and then went quiet never settles it, and deferring would - // leave the request outstanding for the life of the session -- which is the - // timeout case, not a rare one. + // leave the request outstanding for the life of the session. cleanup(); setup.then(cleanup, cleanup); return; @@ -989,7 +987,7 @@ export class Subscriber { #needsLive(producer: track.Producer, edge: bigint): boolean { if (hooks.trackFinished(producer) || !hooks.subscribing(producer).peek()) return false; - const ranges = producer.subscription.peek()?.ranges ?? []; + const ranges = hooks.requested(producer).peek()?.ranges ?? []; if (!bounded(ranges)) return true; const end = wireSpan(ranges).end; // A relative end resolves against Largest, or against the first group when none exists yet, @@ -999,7 +997,7 @@ export class Subscriber { async #historyOnly(producer: track.Producer, edge: bigint): Promise { while (this.#needsLive(producer, edge) && hooks.trackReleased(producer).peek() === undefined) { - await Signal.race(producer.subscription, hooks.subscribing(producer), hooks.trackReleased(producer)); + await Signal.race(hooks.requested(producer), hooks.subscribing(producer), hooks.trackReleased(producer)); } } @@ -1051,7 +1049,7 @@ export class Subscriber { const ok = await untilAborted( race([ deadline.wait(setup()), - demandAbandoned(request.demand()).then((): never => { + unrequested(request).then((): never => { throw new Error("history read abandoned"); }), ]), @@ -1078,7 +1076,7 @@ export class Subscriber { const subscribing = hooks.subscribing(producer); for (;;) { if (hooks.trackReleased(producer).peek() !== undefined) return; - const subscription = producer.subscription.peek(); + const subscription = hooks.requested(producer).peek(); if (!subscription) { hooks.releaseTrack(producer); return; @@ -1089,7 +1087,7 @@ export class Subscriber { continue; } await untilAborted( - Promise.resolve(Signal.race(producer.subscription, subscribing, hooks.trackReleased(producer))), + Promise.resolve(Signal.race(hooks.requested(producer), subscribing, hooks.trackReleased(producer))), signal, ); } @@ -1375,14 +1373,14 @@ export class Subscriber { for (;;) { // With no subscriber left there is nothing to follow: the subscription is ending, or // keeps what it last asked for until one returns, as on lite and in Rust. - const subscription = producer.subscription.peek(); + const subscription = hooks.requested(producer).peek(); const wanted = toWire(subscription?.priority ?? 0); const wantedMaxAge = this.#maxAge && subscription ? maxDelayOf(subscription) : undefined; if (!subscription || (wanted === sent && wantedMaxAge === sentMaxAge) || unanswered.peek() > 0) { const disposes: Dispose[] = []; const wake = new Promise((resolve) => { disposes.push( - producer.subscription.changed(() => resolve()), + hooks.requested(producer).changed(() => resolve()), unanswered.changed(() => resolve()), ); }); @@ -1511,7 +1509,7 @@ export class Subscriber { // Opens the subscribe stream, sends SUBSCRIBE, and reads the response. // `state` is populated as soon as the stream opens and again when the - // trackAlias is registered, so the caller can clean both up on timeout + // trackAlias is registered, so the caller can clean both up once it gives up, // even before this promise settles. async #openSubscribe( state: SubscribeSetupState, @@ -1536,6 +1534,7 @@ export class Subscriber { // that the stream is recorded, so settling this promise hands it back to be torn down. if (cancelled.aborted) throw new Error("subscribe cancelled before it was sent"); + const bounds = wireSpan(request.subscription.ranges ?? [{}]); const msg = new Subscribe({ requestId, trackNamespace: broadcast, @@ -1545,10 +1544,10 @@ export class Subscriber { maxAge: this.#maxAge ? maxDelayOf(request.subscription) : undefined, // Fixed for the subscription's life: no REQUEST_UPDATE parameter carries it. groupOrder: request.subscription.order, - // Joins at the current group, from its first object: the subscription delivers the - // objects after Largest, and the group's head before it comes on a fetch stream - // (see {@link Join}). Fixed for the subscription's life. - ...joinFilter(version), + // A known start is absolute, except group 0, which joins live with no filter. A Next + // Object join recovers its head through a fetch stream (see {@link Join}). Fixed for + // the subscription's life. + ...joinFilter(version, bounds.start), }); if (msg.fill) { // The fill names this request and can overtake SUBSCRIBE_OK, so it waits on it here. @@ -1605,10 +1604,12 @@ export class Subscriber { if (!known) state.requests = producer.requests(); const tail = new Tail(); const subscription: Subscription = { track: producer, tail, timescale: ok.properties.timescale, fetchIds: [] }; - // The subscription starts after Largest, so the joining FETCH or the fill brings its - // group's head. With no Largest the track is empty and every group whole. A draft-22 fill - // is one of the subscription's own streams, so the Stream Count already waits for it. - if (ok.largest) { + // A live join starts at Largest's group: a filterless one from its first object, and Next + // Object after Largest, so the joining FETCH or the fill brings that group's head. With no + // Largest the track is empty and every group whole. A draft-22 fill is one of the + // subscription's own streams, so the Stream Count already waits for it. + const live = msg.filter.kind !== "absolute"; + if (msg.filter.kind === "nextObject" && ok.largest) { subscription.join = new Join(ok.largest.groupId, tail, { counted: msg.fill !== undefined }); const join = subscription.join; this.#joins.set(producer, join); @@ -1624,12 +1625,11 @@ export class Subscriber { // no object, so a Next Object join served onward still starts at the group's first. hooks.reportedGroup(producer, ok.largest.groupId); } - // The live join starts at the publisher's edge, whatever floor the demand asked for. - // With no Largest it skips nothing, so every group is owed: a lower one's stream may - // still arrive after a higher one's. + // Absolute subscriptions keep their prefix even when the answer follows its first data. A + // live join's earlier start is left to the copy's fills, one range FETCH per run. hooks.startTrack(producer, { - start: ok.largest ? ok.largest.groupId : 0n, - requested: wireSpan(request.subscription.ranges ?? [{}]).start, + start: live ? (ok.largest?.groupId ?? 0n) : (bounds.start ?? 0n), + requested: bounds.start, }); staged?.commit(); state.fill?.(subscription); @@ -1856,6 +1856,24 @@ export class Subscriber { } return producer; }; + // A group that will never arrive whole is dropped, so readers stop owing it. + const dropHeadless = () => + hooks.dropTrackGroups( + track, + group.groupId, + group.groupId + 1n, + new StreamError(StreamCode.NotFound, { message: "the group's head was lost" }), + ); + // After an END_OF_GROUP or END_OF_TRACK status only the stream's end may follow: an + // object past the group's final one makes the track malformed. + const last = async () => { + const done = await race([stream.done(), producer?.closed ?? hooks.trackReleased(track)]); + if (done === false) { + throw new StreamError(StreamCode.MalformedTrack, { + message: `data past the group's final object: group=${group.groupId}`, + }); + } + }; try { // The alias binds after SUBSCRIBE_OK commits the track property; an omitted @@ -1909,6 +1927,7 @@ export class Subscriber { console.debug( `dropping a group with no head: alias=${group.trackAlias} group=${group.groupId} start=${start}`, ); + dropHeadless(); stream.stop(new Error("no head to stitch a mid-group stream onto")); return; } @@ -1922,6 +1941,7 @@ export class Subscriber { console.debug( `dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`, ); + dropHeadless(); stream.stop(new Error("a group must start at object 0")); return; } @@ -1933,6 +1953,7 @@ export class Subscriber { // No object at or past this location exists: after the group's last object // the track ends with it, and at object 0 it ends before it. if (producer && frame.idDelta !== 0) throw new ObjectIdGap(frame.idDelta); + await last(); const end = { group: group.groupId, frame: nextObject + frame.idDelta }; producer?.finish(); try { @@ -1943,7 +1964,10 @@ export class Subscriber { subscription.end ??= finalSequence(end); return; } - if (!frame.isObject) break; + if (!frame.isObject) { + await last(); + break; + } // A track that declared TIMESCALE stamps every object, so one without a Timestamp is // malformed rather than something to invent a time for. @@ -1978,6 +2002,7 @@ export class Subscriber { // as the keyframe the group opens with. if (producer === undefined && e instanceof ObjectIdGap && !group.flags.firstObject) { console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); + dropHeadless(); stream.stop(new Error("a group must start at object 0")); return; } diff --git a/js/net/src/index.ts b/js/net/src/index.ts index f22234d3edc..b87a202a8be 100644 --- a/js/net/src/index.ts +++ b/js/net/src/index.ts @@ -32,6 +32,8 @@ export * as Instance from "./instance.ts"; export * as Origin from "./origin.ts"; /** Broadcast path utilities with delimiter-aware prefix matching. Path patterns are re-exported from `@moq/pattern`. */ export * as Path from "./path.ts"; +/** Point-to-point TCP and track sessions through an origin. */ +export * as socket from "./socket.ts"; /** Presentation timestamps and their timescales; duration units live in `@moq/clock`. */ export * as Time from "./time.ts"; /** A value paired with the timestamp it was captured at, absent when untimed. */ diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 68469b293d7..3808ed63d56 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -33,9 +33,8 @@ import { withTimeout } from "./util/timeout.ts"; import { wireOf } from "./wire.ts"; function publish(origin: OriginProducer, path: Path.Valid) { - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; return broadcast; } @@ -891,10 +890,9 @@ test("integration: a peer is served the cheaper route it was offered, not the lo const path = Path.from("test"); // One publisher instance, so the two compete on cost alone. const instance = Instance.random(); - const localPublication = origin.createBroadcast(path); - const local = localPublication.broadcast; - localPublication.announce({ instance, cost: 5n }); - const dynamic = origin.requestsRoute(path, { instance, cost: 1n }); + const localRoute = origin.route(path, { instance, cost: 5n }); + const local = localRoute.broadcast; + const dynamic = origin.routePrefix(path, { instance, cost: 1n }); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), @@ -1264,8 +1262,7 @@ test("integration: ietf fetches a past group of a finished track", async () => { for (const protocol of [Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_20, Ietf.ALPN.DRAFT_22]) { const pair = createMockTransportPair(protocol); const origin = new OriginProducer(); - const publication = origin.createBroadcast(Path.from("test")); - publication.announce(); + const publication = origin.route(Path.from("test")); // No publisher limit, so the superseded group stays fresh to fetch, and no MAX_CACHE_DURATION. const published = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); for (let sequence = 0; sequence < 3; sequence++) { @@ -2617,12 +2614,12 @@ test("create then announce is discoverable on the wire", async () => { ]); const announced = client.routes(); - const broadcastPublication = origin.createBroadcast(Path.from("later")); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = origin.route(Path.from("later"), { hidden: true }); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("chat", { timescale: Timescale.MILLI }); const pending = announced.next(); - broadcastPublication.announce(); + broadcastRoute.announce(); const entry = await pending; expect(entry?.prefix).toBe("later" as Path.Valid); expect(entry?.kind).toBe("start"); @@ -2640,7 +2637,7 @@ test.each([Lite.ALPN_06, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_2 const pair = createMockTransportPair(protocol); const origin = new OriginProducer(); const consumer = new OriginProducer(); - const handle = origin.requests(Path.empty()); + const handle = origin.routePrefix(Path.empty()); const held = new BroadcastProducer(); held.createTrack("chat", { timescale: Timescale.MILLI }).writeFrame(textFrame("hello")); const serving = (async () => { @@ -2674,14 +2671,15 @@ test.each([Lite.ALPN_06, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_2 const pair = createMockTransportPair(protocol); const origin = new OriginProducer(); const consumer = new OriginProducer(); - const handle = origin.requests(Path.empty()); + const handle = origin.routePrefix(Path.empty()); const serving = (async () => { const requests = handle.requested(); const request = (await requests.next()).value; if (!request) throw new Error("missing job request"); - const prepared = request.prepare(); - prepared.broadcast.createTrack("chat", { timescale: Timescale.MILLI }).writeFrame(textFrame("job")); - return prepared.publish(); + const job = request.route({ hidden: true }); + job.broadcast.createTrack("chat", { timescale: Timescale.MILLI }).writeFrame(textFrame("job")); + job.announce(); + return job; })(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client, consume: consumer }), @@ -2692,11 +2690,11 @@ test.each([Lite.ALPN_06, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_2 if (!remote) throw new Error("request did not resolve"); const track = remote.track("chat").subscribe().ordered(); await expect(track.readString()).rejects.toBeInstanceOf(Error); - const publication = await serving; + const job = await serving; track.close(); requested.close(); handle.close(); - publication.broadcast.close(); + job.close(); client.abort(); server.abort(); origin.close(); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 6b1c48be47f..1f068aa70e5 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -12,8 +12,9 @@ import type * as datagram from "./datagram.ts"; import type { Delivered } from "./delivered.ts"; import { toStreamCode } from "./error.ts"; import type { Frame, FrameConsumer, Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; -import { isAnonymous, type Route } from "./hop.ts"; +import { isAnonymous, type RouteInfo } from "./hop.ts"; import type * as Instance from "./instance.ts"; +import type { Consumer as OriginConsumer, Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; import type { @@ -33,6 +34,9 @@ import * as Varint from "./varint.ts"; import type { Resolved, Window } from "./window.ts"; import type { Advertised, Advertisements } from "./wire.ts"; +/** The hidden broadcast segment reserved for socket endpoints. */ +export const SOCKET = ".socket"; + /** What a null {@link Delay.max} goes out as: the largest value every peer can read, as Rust sends for `None`. */ export const NO_MAX_DELAY = Milli(Varint.MAX_U53); @@ -132,12 +136,12 @@ export function presented( * candidate supersedes. Supersession is decided among what the reader may see, so a higher epoch * it cannot see never hides a route it can. */ -export function firstEligible( +export function firstEligible( candidates: readonly T[], admits: (candidate: T) => boolean = () => true, ): T | undefined { const admitted = candidates.filter(admits); - const byPrefix = new Map(); + const byPrefix = new Map(); for (const candidate of admitted) { const routes = byPrefix.get(candidate.prefix); if (routes) routes.push(candidate.route); @@ -336,6 +340,8 @@ export interface Land { * buffer of its own. Implemented by `resume.ts`, the counterpart of the Rust `Inner::Resume`. */ export interface ResumeSource { + /** Observe a serving copy's abort, including delivery failures a resumable reader waits past. */ + watchError(fn: (error: Error) => void): Dispose; /** Re-evaluate a live-edge join at the serving copy. */ join(): void; /** @@ -403,6 +409,8 @@ export interface TrackRequestOptions { producer: Producer; /** Requests not yet accepted or rejected by the publisher. */ pending: Set; + /** Metadata-only readers holding this request, if its broadcast supports them. */ + lookups?: { pending: Getter; opened: boolean }; } /** @@ -644,6 +652,8 @@ export const hooks: { fetchBudget: (subscriber: Subscriber, maxDelay: Milli | undefined) => void; /** Whether a reader beyond one-shot fetches subscribes to this copy. */ subscribing: (producer: Producer) => Getter; + /** The aggregate across every reader, one-shot fetches included: what the wire asks for. */ + requested: (producer: Producer) => Getter; /** Leave a wire copy serving its cache and fills without a live feed. */ cacheTrack: (producer: Producer) => void; /** Fail subscriptions reaching beyond `edge`, keeping history readers and one-shot fetches. */ @@ -728,14 +738,23 @@ export const hooks: { * mirror is released too: not when its group reads on, or may yet, from another copy. */ evictGroup: (group: GroupConsumer) => boolean; - /** Attach the origin advertisement of a created broadcast. */ - attachAnnouncer: ( - producer: BroadcastProducer, - announcer: { announce(route: Route): void; unannounce(): void; route(): Route | undefined }, - ) => void; - /** Call `fn` synchronously when an open broadcast closes, from any handle. */ onClosed: (producer: BroadcastProducer, fn: () => void) => Dispose; + /** Reserve an on-demand track without listing it or enabling a request handler. */ + reserveUnlistedTrack: (producer: BroadcastProducer, name: string) => Request; + /** Whether a subscriber or a metadata lookup still holds a requested track. */ + requestWanted: (request: Request) => Getter; + /** Transfer a metadata-opened request to its session's producer lifetime. */ + keepRequest: (request: Request) => void; + /** Observe the peer's reset without waiting for routed delivery recovery. */ + watchSubscriberError: (subscriber: Subscriber, fn: (error: Error) => void) => Dispose; + /** + * Count a session withholding the origin's hidden broadcasts from its peer, such as a + * moq-lite client before 07, whose peer can't ask for them, until disposed. + */ + withholdHidden: (origin: OriginConsumer) => Dispose; + /** How many sessions withhold the origin's hidden broadcasts from their peer. */ + hiddenWithheld: (origin: OriginProducer) => Getter; /** Name a broadcast handle by the path an origin created or resolved it at. */ stampPath: (target: BroadcastProducer | BroadcastConsumer, path: Path.Valid) => void; /** Name the instance of the route an origin resolved a broadcast handle through. */ @@ -916,6 +935,9 @@ export const hooks: { subscribing: () => { throw new Error("track module not loaded"); }, + requested: () => { + throw new Error("track.ts not loaded"); + }, fetchBudget: () => { throw new Error("track.ts not loaded"); }, @@ -1012,13 +1034,27 @@ export const hooks: { evictGroup: () => { throw new Error("group.ts not loaded"); }, - attachAnnouncer: () => { + onClosed: () => { throw new Error("broadcast.ts not loaded"); }, - - onClosed: () => { + reserveUnlistedTrack: () => { throw new Error("broadcast.ts not loaded"); }, + requestWanted: () => { + throw new Error("track.ts not loaded"); + }, + keepRequest: () => { + throw new Error("track.ts not loaded"); + }, + watchSubscriberError: () => { + throw new Error("track.ts not loaded"); + }, + withholdHidden: () => { + throw new Error("origin.ts not loaded"); + }, + hiddenWithheld: () => { + throw new Error("origin.ts not loaded"); + }, stampPath: () => { throw new Error("broadcast.ts not loaded"); }, @@ -1083,7 +1119,7 @@ export function* coveringPrefixes(path: Path.Valid): Generator { * a higher epoch at the same prefix. An older incarnation of an instance is never eligible, * however cheap, since its publisher was replaced. */ -export function superseded(route: Route, others: Iterable): boolean { +export function superseded(route: RouteInfo, others: Iterable): boolean { return new Epochs(others).supersedes(route); } @@ -1095,7 +1131,7 @@ export function superseded(route: Route, others: Iterable): boolean { export class Epochs { readonly #highest = new Map(); - constructor(routes: Iterable) { + constructor(routes: Iterable) { for (const route of routes) { const epoch = route.epoch ?? 0n; if (epoch === 0n) continue; @@ -1105,14 +1141,14 @@ export class Epochs { } /** Whether a route carries `route`'s instance at a higher epoch. */ - supersedes(route: Route): boolean { + supersedes(route: RouteInfo): boolean { const highest = this.#highest.get(route.instance); return highest !== undefined && highest > (route.epoch ?? 0n); } } /** Prefer identified publishers, then lower static cost. */ -export function compareRoutes(a: Route, b: Route): number { +export function compareRoutes(a: RouteInfo, b: RouteInfo): number { return Number(isAnonymous(a)) - Number(isAnonymous(b)) || (a.cost < b.cost ? -1 : a.cost > b.cost ? 1 : 0); } @@ -1124,7 +1160,7 @@ export function compareRoutes(a: Route, b: Route): number { * matching `route_order` in rs/moq-net for routes a node did not get from a cluster peer, which * is every route here. */ -export function compareRouteCandidates(path: Path.Valid, prefix: Path.Valid, a: Route, b: Route): number { +export function compareRouteCandidates(path: Path.Valid, prefix: Path.Valid, a: RouteInfo, b: RouteInfo): number { const order = compareRoutes(a, b) || a.hops.length - b.hops.length; if (order !== 0 || path === prefix) return order; const ha = spreadHash(path, a.instance ?? ""); diff --git a/js/net/src/lite/compression.test.ts b/js/net/src/lite/compression.test.ts index 43d7d1c9a41..d6d32fb03f8 100644 --- a/js/net/src/lite/compression.test.ts +++ b/js/net/src/lite/compression.test.ts @@ -13,11 +13,11 @@ for (const downstream of [ALPN_06, ALPN_07_WIP]) { const installed = clock.install(); const source = new Producer(); const destination = new Producer(); - const broadcastPublication = source.createBroadcast(Path.from("compressed")); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = source.route(Path.from("compressed"), { hidden: true }); + const broadcast = broadcastRoute.broadcast; const datagrams = broadcast.createDatagrams("data"); const track = broadcast.createTrack("data", { compress: true }); - broadcastPublication.announce(); + broadcastRoute.announce(); const peers: { abort(): void }[] = []; async function link(publish: Producer, consume: Producer, alpn: string) { const pair = createMockTransportPair(alpn); diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 48690d2f88c..a59006e6042 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -309,9 +309,8 @@ for (const [alpn, version] of [ const pair = createMockTransportPair(alpn); const fin = holdFins(pair.server, "uni"); const origin = new Producer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const url = new URL("https://localhost/test"); const client = new Connection({ url, quic: pair.client, version }); @@ -371,9 +370,8 @@ for (const [alpn, version] of [ deliverWhole(pair.client); if (overtaken === "fin") finAfterClose(pair.client); const origin = new Producer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const url = new URL("https://localhost/test"); const client = new Connection({ url, quic: pair.client, version }); @@ -423,9 +421,8 @@ for (const [alpn, version] of [ const pair = createMockTransportPair(alpn); finAfterClose(pair.client); const origin = new Producer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const url = new URL("https://localhost/test"); const client = new Connection({ url, quic: pair.client, version }); @@ -468,9 +465,8 @@ for (const [alpn, version] of [ test(`a peer close finishes a FIN'd track over qmux ${versionName(version)}`, async () => { const pair = qmuxPair(); const origin = new Producer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const url = new URL("https://localhost/test"); const client = new Connection({ url, quic: pair.client, version }); @@ -509,9 +505,8 @@ for (const [alpn, version] of [ async function servedRawSubscription(alpn: string = ALPN_07_WIP, version: Version = Version.DRAFT_07) { const pair = createMockTransportPair(alpn); const origin = new Producer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const server = new Connection({ url: new URL("https://relay.example/"), @@ -628,9 +623,8 @@ test("close waits for a served FETCH to be acknowledged", async () => { const pair = createMockTransportPair(ALPN_05); const fin = holdFins(pair.server, "incoming-bidi"); const origin = new Producer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); @@ -681,9 +675,8 @@ test("close waits for a request served while withdrawals are in flight", async ( const fin = holdFins(pair.server, "incoming-bidi"); const source = new Producer(); const destination = new Producer(); - const broadcastPublication = source.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = source.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index d5aa87581aa..60153010cdd 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -10,6 +10,7 @@ import { type Transport, transportOf } from "../connection/transport.ts"; import { closeError, error, fromClose, ProtocolViolation, StreamCode, sessionCause } from "../error.ts"; import { type Hop, randomHop } from "../hop.ts"; import { IMPLEMENTATION } from "../implementation.ts"; +import { hooks } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Reader, Readers, Stream, Writer } from "../stream.ts"; @@ -24,6 +25,7 @@ import { Fetch } from "./fetch.ts"; import { Goaway } from "./goaway.ts"; import { Group } from "./group.ts"; import { Publisher } from "./publisher.ts"; +import { proveRouteKey } from "./route_key.ts"; import { SessionInfo } from "./session.ts"; import { hasClientParameters, ProbeLevel, type Role, Setup } from "./setup.ts"; import { DataType, StreamId } from "./stream.ts"; @@ -36,8 +38,10 @@ import { hasDatagrams, hasDatagramTracks, hasGoaway, + hasHidden, hasPadding, hasProbeRtt, + hasRouteKey, hasSetupStream, hasTrackStream, hasTracks, @@ -213,6 +217,13 @@ export class Connection implements Established { // finds this session still active. const leave = () => this.#auth.close(); void quic.closed.then(leave, leave); + // Before lite-07 the peer can't ask for hidden routes, so a client publishes none of + // them, and a socket endpoint behind it is unreachable. A server leaves it to the + // client, whose origin holds that endpoint. + if (client && publish && !hasHidden(version)) { + const withholding = hooks.withholdHidden(publish); + void quic.closed.then(withholding, withholding); + } this.#auth.violated.subscribe((err) => { if (!err) return; closeViolation(this.#quic, err); @@ -384,10 +395,14 @@ export class Connection implements Established { try { await writer.u53(DataType.Setup); const probe = await probeLevel(this.#quic, this.#version); - await new Setup({ probe, hop: this.hop, token: this.#token, implementation: this.#implementation }).encode( - writer, - this.#version, - ); + const routeKey = hasRouteKey(this.#version) ? await proveRouteKey(this.#quic, this.#client) : undefined; + await new Setup({ + probe, + hop: this.hop, + token: this.#token, + implementation: this.#implementation, + routeKey, + }).encode(writer, this.#version); writer.close(); } catch (err: unknown) { writer.reset(err); @@ -534,7 +549,9 @@ export class Connection implements Established { /** Snapshot the transport's counters; see {@link Established.stats}. */ async stats(): Promise { - return transportStats(this.#quic); + const stats = await transportStats(this.#quic); + if (hasDatagrams(this.#version)) stats.datagramsUnrouted = this.#subscriber.datagramsUnrouted; + return stats; } /** Settles with the peer's GOAWAY; see {@link Established.draining}. */ diff --git a/js/net/src/lite/copy_fill.test.ts b/js/net/src/lite/copy_fill.test.ts index 68bf496cd99..48f282ee605 100644 --- a/js/net/src/lite/copy_fill.test.ts +++ b/js/net/src/lite/copy_fill.test.ts @@ -28,8 +28,7 @@ async function peers(alpn: string) { connectSession({ url, transport: pair.client }), acceptSession({ transport: pair.server, url, publish: origin.consume() }), ]); - const publication = origin.createBroadcast(Path.from("room")); - publication.announce(); + const publication = origin.route(Path.from("room")); const broadcast = publication.broadcast; const track = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); const requests = track.requests(); @@ -238,8 +237,7 @@ test("copy fill: a filled group stops downloading once every reader left or pass connectSession({ url, transport: pair.client }), acceptSession({ transport: pair.server, url, publish: origin.consume() }), ]); - const publication = origin.createBroadcast(Path.from("room")); - publication.announce(); + const publication = origin.route(Path.from("room")); const track = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); write(track.createGroup(1n), 1n, "live"); // The archive answers group 0 with one frame and keeps it open. @@ -304,8 +302,7 @@ test("copy fill: a filled group nobody holds any more stops downloading", async connectSession({ url, transport: pair.client }), acceptSession({ transport: pair.server, url, publish: origin.consume() }), ]); - const publication = origin.createBroadcast(Path.from("room")); - publication.announce(); + const publication = origin.route(Path.from("room")); const track = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); write(track.createGroup(1n), 1n, "live"); // The archive answers group 0 with one frame and never finishes it. diff --git a/js/net/src/lite/datagram-tracks.test.ts b/js/net/src/lite/datagram-tracks.test.ts index c5138d7c0f3..a79af11067f 100644 --- a/js/net/src/lite/datagram-tracks.test.ts +++ b/js/net/src/lite/datagram-tracks.test.ts @@ -1,10 +1,13 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import { yieldNow } from "@moq/clock"; +import { Signal } from "@moq/signals"; import { accept, connect } from "../connection/index.ts"; -import { createMockTransportPair } from "../mock.ts"; +import { createMockTransportPair, type MockTransport } from "../mock.ts"; import { Producer as Origin } from "../origin.ts"; import * as Path from "../path.ts"; import { sendOrder } from "../priority.ts"; import { Timescale, Timestamp } from "../time.ts"; +import { Budget } from "../util/yield.ts"; import { wireOf } from "../wire.ts"; import { ALPN_04, ALPN_05, ALPN_06, ALPN_07_WIP } from "./version.ts"; @@ -15,7 +18,7 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { for (const streams of [false, true]) { test(`${alpn}: ${streams ? "same-name kinds" : "datagram-only track"} delivery`, async () => { const source = new Origin(); - const publication = source.createBroadcast(path); + const publication = source.route(path, { hidden: true }); const datagrams = publication.broadcast.createDatagrams("audio", { timescale: Timescale.MICRO, priority: -7, @@ -104,7 +107,7 @@ test("lite-04 refuses datagram tracks at setup", async () => { test("lite-07 bounds and prioritizes independent createWritable datagram queues", async () => { const source = new Origin(); - const publication = source.createBroadcast(path); + const publication = source.route(path, { hidden: true }); const datagrams = publication.broadcast.createDatagrams("audio"); publication.broadcast.createTrack("audio", { compress: true }); publication.announce(); @@ -155,7 +158,7 @@ for (const alpn of [ALPN_05, ALPN_06]) for (const first of ["stream", "datagram"]) { test(`${alpn}: datagram readers rejoin and outlive streams (${first} first)`, async () => { const source = new Origin(); - const publication = source.createBroadcast(path); + const publication = source.route(path, { hidden: true }); const datagrams = publication.broadcast.createDatagrams("audio"); const track = publication.broadcast.createTrack("audio"); publication.announce(); @@ -200,7 +203,7 @@ for (const alpn of [ALPN_05, ALPN_06]) test("lite-07 forwards a priority change made while the previous DATAGRAMS_UPDATE writes", async () => { const source = new Origin(); - const publication = source.createBroadcast(path); + const publication = source.route(path, { hidden: true }); const datagrams = publication.broadcast.createDatagrams("audio"); publication.announce(); const pair = createMockTransportPair(ALPN_07_WIP); @@ -231,7 +234,7 @@ test("lite-07 forwards a priority change made while the previous DATAGRAMS_UPDAT test("lite-06: a request cancelled behind the shared datagram writer sends nothing", async () => { const source = new Origin(); - const publication = source.createBroadcast(path); + const publication = source.route(path, { hidden: true }); const first = publication.broadcast.createDatagrams("a"); const second = publication.broadcast.createDatagrams("b"); publication.announce(); @@ -281,3 +284,311 @@ test("lite-06: a request cancelled behind the shared datagram writer sends nothi source.close(); } }); + +// A publisher's stream writes, handed to the subscriber `lag` microtasks late: the answer it +// writes lands after a datagram sent behind it, though in the same host task. +function lagAnswers(transport: MockTransport, lag: number): void { + if (lag === 0) return; + const lagging = (writable: WritableStream) => { + const writer = writable.getWriter(); + let chain = Promise.resolve(); + const later = (op: () => Promise) => { + // A write the session's close fails is lost, as it would be on the wire. + chain = chain + .then(async () => { + for (let i = 0; i < lag; i++) await Promise.resolve(); + await op(); + }) + .catch(() => {}); + return chain; + }; + return new WritableStream({ + // Copied: the write resolves now, after which the stream may reuse its buffer. + write: (chunk) => { + const copy = chunk.slice(); + void later(() => writer.write(copy)); + }, + close: () => later(() => writer.close()), + abort: (reason) => writer.abort(reason), + }); + }; + const incoming = transport.incomingBidirectionalStreams.pipeThrough( + new TransformStream({ + transform: (bidi, controller) => + controller.enqueue({ + readable: bidi.readable, + writable: lagging(bidi.writable), + } as WebTransportBidirectionalStream), + }), + ); + Object.defineProperty(transport, "incomingBidirectionalStreams", { value: incoming }); +} + +// A publisher that writes as soon as a subscription reaches it sends the datagram right behind +// its answer, which may reach the subscriber first, with the track's end too when it finishes at +// once. The subscriber decodes the answer before it routes the datagram, and routes the datagram +// before it ends the track. +for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { + for (const [lag, finish] of [ + [0, false], + [100, false], + [0, true], + [100, true], + ] as const) { + test(`${alpn}: a datagram sent right behind its answer arrives (answer ${lag} microtasks late${finish ? ", then the end" : ""})`, async () => { + const source = new Origin(); + const publication = source.route(path, { hidden: true }); + const datagrams = publication.broadcast.createDatagrams("audio"); + publication.announce(); + const pair = createMockTransportPair(alpn); + lagAnswers(pair.server, lag); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ url, transport: pair.server, publish: source.consume() }), + ]); + const remote = wireOf(client).consume(path); + const reader = remote.datagrams("audio"); + // Only the subscription carries this priority: a lite-06 TRACK lookup asks for none. + reader.setPriority(5); + try { + while (datagrams.priority.peek() !== 5) await datagrams.priority.changed(); + datagrams.append(undefined, Uint8Array.of(1)); + if (finish) datagrams.finish(); + await reader.info(); + if (!finish) datagrams.append(undefined, Uint8Array.of(2)); + expect((await reader.recv())?.payload).toEqual(Uint8Array.of(1)); + if (finish) expect(await reader.recv()).toBeUndefined(); + else expect((await reader.recv())?.payload).toEqual(Uint8Array.of(2)); + expect((await client.stats()).datagramsUnrouted).toBe(0); + } finally { + reader.close(); + remote.close(); + client.abort(); + server.abort(); + source.close(); + } + }); + } +} + +// A publisher that writes a last datagram and finishes at once sends the datagram right ahead +// of its track's end. The subscriber routes the datagram before it ends the track. +for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { + test(`${alpn}: a datagram sent right before its end arrives`, async () => { + const source = new Origin(); + const publication = source.route(path, { hidden: true }); + const datagrams = publication.broadcast.createDatagrams("audio"); + publication.announce(); + const pair = createMockTransportPair(alpn); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ url, transport: pair.server, publish: source.consume() }), + ]); + const remote = wireOf(client).consume(path); + const reader = remote.datagrams("audio"); + reader.setPriority(5); + try { + await reader.info(); + while (datagrams.priority.peek() !== 5) await datagrams.priority.changed(); + datagrams.append(undefined, Uint8Array.of(1)); + datagrams.finish(); + expect((await reader.recv())?.payload).toEqual(Uint8Array.of(1)); + expect(await reader.recv()).toBeUndefined(); + expect((await client.stats()).datagramsUnrouted).toBe(0); + } finally { + reader.close(); + remote.close(); + client.abort(); + server.abort(); + source.close(); + } + }); +} + +// A burst written right before a track's end can outlast the receive loop's budget, which yields +// with some of it still queued. The end waits for the loop to read the queue empty. +for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { + test(`${alpn}: a burst sent right before its end arrives past a budget yield`, async () => { + // As many as a datagram track buffers unread, past two receive budgets. + const BURST = 64; + const source = new Origin(); + const publication = source.route(path, { hidden: true }); + const datagrams = publication.broadcast.createDatagrams("audio"); + publication.announce(); + const pair = createMockTransportPair(alpn); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ url, transport: pair.server, publish: source.consume() }), + ]); + const remote = wireOf(client).consume(path); + const reader = remote.datagrams("audio"); + reader.setPriority(5); + // Every budget slice reads as spent, so the receive loop yields a host task every 32 checks. + let checks = 0; + const budget = spyOn(Budget.prototype, "poll").mockImplementation(() => + ++checks % 32 === 0 ? yieldNow() : undefined, + ); + try { + await reader.info(); + while (datagrams.priority.peek() !== 5) await datagrams.priority.changed(); + for (let i = 0; i < BURST; i++) datagrams.append(undefined, Uint8Array.of(i)); + datagrams.finish(); + let count = 0; + while ((await reader.recv()) !== undefined) count++; + expect(count).toBe(BURST); + expect((await client.stats()).datagramsUnrouted).toBe(0); + } finally { + budget.mockRestore(); + reader.close(); + remote.close(); + client.abort(); + server.abort(); + source.close(); + } + }); +} + +// A track's end waits a host task for the datagrams received with it. A session close in +// between finishes the track rather than cutting it off: the publisher already ended it. +for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { + test(`${alpn}: a session close right after a datagram track's end keeps the end`, async () => { + const source = new Origin(); + const publication = source.route(path, { hidden: true }); + const datagrams = publication.broadcast.createDatagrams("audio"); + publication.announce(); + const pair = createMockTransportPair(alpn); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ url, transport: pair.server, publish: source.consume() }), + ]); + const remote = wireOf(client).consume(path); + const reader = remote.datagrams("audio"); + reader.setPriority(5); + try { + await reader.info(); + while (datagrams.priority.peek() !== 5) await datagrams.priority.changed(); + datagrams.finish(); + // The mock delivers the end in microtasks, all within this host task. + for (let i = 0; i < 1000; i++) await Promise.resolve(); + client.abort(); + expect(await reader.recv()).toBeUndefined(); + } finally { + reader.close(); + remote.close(); + client.abort(); + server.abort(); + source.close(); + } + }); +} + +// Holds back what the publisher writes on the next request stream it accepts once armed, until +// released: that subscription's answer stays outstanding while everything else flows. +function holdNextAnswer(transport: MockTransport): { arm(): void; release(): void } { + let armed = false; + const release = Promise.withResolvers(); + const holding = (writable: WritableStream) => { + const writer = writable.getWriter(); + let chain = release.promise; + // A write the session's close fails is lost, as it would be on the wire. + const later = (op: () => Promise) => { + chain = chain.then(op).catch(() => {}); + return chain; + }; + return new WritableStream({ + // Copied: the write resolves now, after which the stream may reuse its buffer. + write: (chunk) => { + const copy = chunk.slice(); + void later(() => writer.write(copy)); + }, + close: () => void later(() => writer.close()), + abort: (reason) => writer.abort(reason), + }); + }; + const incoming = transport.incomingBidirectionalStreams.pipeThrough( + new TransformStream({ + transform: (bidi, controller) => { + const held = armed; + armed = false; + controller.enqueue( + held + ? ({ + readable: bidi.readable, + writable: holding(bidi.writable), + } as WebTransportBidirectionalStream) + : bidi, + ); + }, + }), + ); + Object.defineProperty(transport, "incomingBidirectionalStreams", { value: incoming }); + return { + arm: () => { + armed = true; + }, + release: () => release.resolve(), + }; +} + +// Datagrams held for a track still awaiting its answer, more than are held at once, don't hold +// up an answered track's last datagram, which its end, arriving with it, would otherwise drop. +test("lite-07: an unanswered track's datagrams don't delay another track's last", async () => { + const source = new Origin(); + const publication = source.route(path, { hidden: true }); + const b = publication.broadcast.createDatagrams("b"); + // Answers "a" on demand. + const requested = wireOf(publication.broadcast).requestedDatagrams(); + publication.announce(); + const pair = createMockTransportPair(ALPN_07_WIP); + const hold = holdNextAnswer(pair.server); + // Counts the datagrams the publisher sends. + const sending = pair.server.datagrams.writable.getWriter(); + let sent = 0; + const sends = new Signal(0); + Object.defineProperty(pair.server.datagrams, "writable", { + value: new WritableStream({ + write: async (chunk) => { + await sending.write(chunk); + sends.set(++sent); + }, + }), + }); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ url, transport: pair.server, publish: source.consume() }), + ]); + const remote = wireOf(client).consume(path); + const readerB = remote.datagrams("b"); + readerB.setPriority(5); + let readerA: ReturnType | undefined; + try { + await readerB.info(); + while (b.priority.peek() !== 5) await b.priority.changed(); + hold.arm(); + readerA = remote.datagrams("a"); + const request = await requested; + if (!request) throw new Error("the request never reached the handler"); + const a = request.accept({}); + for (let i = 0; i < 40; i++) a.append(undefined, Uint8Array.of(i)); + // "a"'s datagrams reach the subscriber ahead of "b"'s. + while (sends.peek() < 40) await sends.changed(); + b.append(undefined, Uint8Array.of(9)); + b.finish(); + expect((await readerB.recv())?.payload).toEqual(Uint8Array.of(9)); + expect(await readerB.recv()).toBeUndefined(); + // Those past the cap were dropped at once, without stalling "b". + expect((await client.stats()).datagramsUnrouted).toBeGreaterThanOrEqual(8); + // "a" stays unanswered past the host task its held datagrams wait, so they drop too. + while (((await client.stats()).datagramsUnrouted ?? 0) < 40) await yieldNow(); + expect((await client.stats()).datagramsUnrouted).toBe(40); + hold.release(); + await readerA.info(); + } finally { + readerA?.close(); + readerB.close(); + remote.close(); + client.abort(); + server.abort(); + source.close(); + } +}); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index f2aa0d241e9..eae0748cf2f 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -32,9 +32,8 @@ import { Track as TrackMessage } from "./track.ts"; import { ALPN_05, ALPN_06, ALPN_07_WIP, hasRanges, keepsSubscribeOpen, Version } from "./version.ts"; function publish(origin: OriginProducer, path: Path.Valid) { - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; return broadcast; } @@ -116,9 +115,8 @@ test.each([ const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("cam")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce({ cost: 7n }); + const broadcastRoute = origin.route(Path.from("cam"), { cost: 7n }); + const broadcast = broadcastRoute.broadcast; const written: Uint8Array[] = []; const stream = new Stream({ @@ -136,7 +134,7 @@ test.each([ const initial = written.length; expect(initial).toBeGreaterThan(0); - broadcastPublication.announce({ cost: 9n }); + broadcastRoute.setCost(9n); await settle(); expect(written.length > initial).toBe(sent); @@ -734,7 +732,7 @@ async function servedSubscription( broadcast = new BroadcastProducer(); const front = new Front(); front.serve(broadcast.consume()); - const dynamic = origin.requestsRoute(Path.from("test")); + const dynamic = origin.routePrefix(Path.from("test")); void (async () => { for await (const request of dynamic.requested()) request.serve(front.consumer); })(); @@ -2831,9 +2829,8 @@ test("lite draft-06: announces wait for the setup grant", async () => { answered = resolve; }); const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { grant, ready }); - const broadcastPublication = origin.createBroadcast(Path.from("foo/bar")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("foo/bar")); + const broadcast = broadcastRoute.broadcast; const written: Uint8Array[] = []; const stream = new Stream({ @@ -2909,7 +2906,7 @@ test("lite draft-06: closing while announces wait for the setup grant does not h test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", async () => { const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const requests = handle.requested(); const grant = new Signal(grantOf("live")); const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { @@ -2950,7 +2947,7 @@ test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", as test("lite draft-06: a grant that shrinks while TRACK_INFO is blocked resets it", async () => { const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const requests = handle.requested(); const grant = new Signal(grantOf("live")); const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { @@ -3013,7 +3010,7 @@ test("lite draft-06: a grant that shrinks while TRACK_INFO is blocked resets it" test("lite draft-06: a grant that shrinks while the broadcast resolves resets the subscription", async () => { const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const requests = handle.requested(); const grant = new Signal(grantOf("live")); const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 8210e0b0115..4e75c4430c3 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -19,7 +19,7 @@ import { unauthorized, } from "../error.ts"; import type * as group from "../group.ts"; -import { Cost, type Hop, type Route, routesEqual } from "../hop.ts"; +import { Cost, type Hop, type RouteInfo, routesEqual } from "../hop.ts"; import { finalSequence, hiddenBelow, hooks, insertRun, presented, type Run, runGaps } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; @@ -601,15 +601,15 @@ export class Publisher { let nextAnnounceId = 0n; const announceIds = new Map(); - const wireHops = (route: Route): Hop[] => { - if (hasAnnounceOk(this.version)) return route.hops; + const wireHops = (route: RouteInfo): Hop[] => { + if (hasAnnounceOk(this.version)) return [...route.hops]; return [...route.hops, this.hop]; }; // What the peer decodes for a route: pre-lite-06 wires carry no cost, so a re-price // there must not restart. // moq-lite carries no per-request auth, so a swap alone sends nothing. - const onWire = (route: Route): Route => ({ + const onWire = (route: RouteInfo): RouteInfo => ({ ...route, cost: hasRouteCost(this.version) ? route.cost : Cost.zero, auth: undefined, @@ -621,7 +621,7 @@ export class Publisher { !sameInstance(prev, next) || (hasInstance(this.version) && (prev.route.epoch ?? 0n) !== (next.route.epoch ?? 0n)); - const announce = async (suffix: Path.Valid, route: Route) => { + const announce = async (suffix: Path.Valid, route: RouteInfo) => { console.debug(`announce: broadcast=${suffix} active=true instance=${route.instance}`); if (hasAnnounceId(this.version)) announceIds.set(suffix, nextAnnounceId++); await encodeAnnounceBroadcast( @@ -638,7 +638,7 @@ export class Publisher { ); }; - const update = async (suffix: Path.Valid, route: Route) => { + const update = async (suffix: Path.Valid, route: RouteInfo) => { if (!hasAnnounceId(this.version)) { await retract(suffix); await announce(suffix, route); @@ -658,7 +658,7 @@ export class Publisher { }; // Another instance or epoch: ANNOUNCE_RESTART on lite-07, an end and a start before it. - const restart = async (suffix: Path.Valid, route: Route) => { + const restart = async (suffix: Path.Valid, route: RouteInfo) => { const id = announceIds.get(suffix); if (id === undefined || !hasAnnounceRestart(this.version)) { await retract(suffix); diff --git a/js/net/src/lite/refresh.test.ts b/js/net/src/lite/refresh.test.ts index 90e2ad341a0..15378a82123 100644 --- a/js/net/src/lite/refresh.test.ts +++ b/js/net/src/lite/refresh.test.ts @@ -13,9 +13,8 @@ test.each([ALPN_06, ALPN_07_WIP])("refresh joins reach a browser publisher on %s const installed = clock.install(); const source = new Producer(); const b = new Producer(); - const broadcastPublication = source.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = source.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("data", { refresh: 1 }); const pair = createMockTransportPair(alpn); const url = new URL("https://localhost/refresh"); diff --git a/js/net/src/lite/route_key.test.ts b/js/net/src/lite/route_key.test.ts new file mode 100644 index 00000000000..d782acdb8dd --- /dev/null +++ b/js/net/src/lite/route_key.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { proveRouteKey, sign } from "./route_key.ts"; + +// PKCS#8 v1 (RFC 8410) prefix of an Ed25519 key, up to its 32-byte seed. +const PKCS8_SEED_PREFIX = [ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, +]; + +// Shared with rs/moq-net/src/route/proof.rs, so both sides sign the same transcript. +const SEED = new Uint8Array(32).fill(1); +const SECRET = new Uint8Array(32).fill(2); +const CLIENT_PROOF = + "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c" + + "e7ea4e34a65b8501185b805d09b58a61b5b8495654fcaac0949d4f98aa35002e" + + "6fca096329973e91e1105030dd88fa0eb543c68c00e18394f8300cb0574cf901"; + +async function keyFromSeed(seed: Uint8Array): Promise { + const der = new Uint8Array([...PKCS8_SEED_PREFIX, ...seed]); + const privateKey = await crypto.subtle.importKey("pkcs8", der, { name: "Ed25519" }, true, ["sign"]); + // WebCrypto derives the public key only through a JWK export. + const jwk = await crypto.subtle.exportKey("jwk", privateKey); + const publicKey = await crypto.subtle.importKey( + "jwk", + { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, + { name: "Ed25519" }, + true, + ["verify"], + ); + return { privateKey, publicKey }; +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +test("a proof matches the Rust transcript", async () => { + const proof = await sign(await keyFromSeed(SEED), SECRET, true); + expect(hex(proof)).toBe(CLIENT_PROOF); +}); + +test("the prover's side changes the signature", async () => { + const key = await keyFromSeed(SEED); + const client = await sign(key, SECRET, true); + const server = await sign(key, SECRET, false); + expect(client.subarray(0, 32)).toEqual(server.subarray(0, 32)); + expect(client.subarray(32)).not.toEqual(server.subarray(32)); +}); + +test("a transport without an exporter proves nothing", async () => { + expect(await proveRouteKey({} as WebTransport, true)).toBeUndefined(); +}); + +test("a transport with an exporter proves one key per page", async () => { + const labels: string[] = []; + const transport = { + exportKeyingMaterial: async (label: BufferSource, context: BufferSource, length: number) => { + labels.push(new TextDecoder().decode(label)); + expect((context as Uint8Array).byteLength).toBe(0); + return new Uint8Array(length).fill(3); + }, + } as unknown as WebTransport; + const first = await proveRouteKey(transport, true); + const second = await proveRouteKey(transport, false); + expect(first?.byteLength).toBe(96); + expect(first?.subarray(0, 32)).toEqual(second?.subarray(0, 32)); + expect(labels).toEqual(["EXPORTER-moq-lite-route-key", "EXPORTER-moq-lite-route-key"]); +}); + +test("unavailable Ed25519 leaves this page unproven across sessions", () => { + // A child has its own page capability cache, independent of the successful proof tests. + const moduleUrl = new URL("./route_key.ts", import.meta.url).href; + const child = Bun.spawnSync([ + process.execPath, + "-e", + ` + import { strict as assert } from "node:assert"; + import { proveRouteKey } from ${JSON.stringify(moduleUrl)}; + let calls = 0; + crypto.subtle.generateKey = async () => { calls++; throw new Error("unavailable"); }; + const transport = { exportKeyingMaterial: async () => new Uint8Array(32) }; + assert.equal(await proveRouteKey(transport, true), undefined); + assert.equal(await proveRouteKey(transport, true), undefined); + assert.equal(calls, 1); + `, + ]); + expect(child.stderr.toString()).toBe(""); + expect(child.exitCode).toBe(0); +}); diff --git a/js/net/src/lite/route_key.ts b/js/net/src/lite/route_key.ts new file mode 100644 index 00000000000..5c2c2fffa77 --- /dev/null +++ b/js/net/src/lite/route_key.ts @@ -0,0 +1,52 @@ +/** + * Route key proofs: an endpoint shows its peer that it holds an Ed25519 key, bound to the + * session by keying material both ends export from its handshake. + * + * A tab proves one key per page load, so a peer recognizes it across its sessions. Only a + * transport that exposes `exportKeyingMaterial` can bind a proof; elsewhere the tab sends + * none and stays unproven. + * + * @module + */ + +const EXPORTER_LABEL = new TextEncoder().encode("EXPORTER-moq-lite-route-key"); +const DOMAIN = new TextEncoder().encode("moq-lite route key"); +const SECRET_SIZE = 32; + +/** The keying material export a WebTransport may offer; only some browsers ship it. */ +type Exporter = (label: BufferSource, context: BufferSource, outputLength: number) => Promise; + +let pageKey: Promise | undefined; + +/** + * The proof to send in SETUP for `transport`, or `undefined` when it cannot export keying + * material. `client` is whether this endpoint dialed the session. + */ +export async function proveRouteKey(transport: WebTransport, client: boolean): Promise { + const exporter = (transport as { exportKeyingMaterial?: Exporter }).exportKeyingMaterial; + if (typeof exporter !== "function") return undefined; + const secret = new Uint8Array(await exporter.call(transport, EXPORTER_LABEL, new Uint8Array(), SECRET_SIZE)); + // Select page capability once: unavailable Ed25519 leaves sessions unproven. + pageKey ??= ( + crypto.subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]) as Promise + ).catch(() => undefined); + const key = await pageKey; + return key ? sign(key, secret, client) : undefined; +} + +/** Sign the proof for `secret`, the session's exported keying material. Exported for tests. */ +export async function sign(key: CryptoKeyPair, secret: Uint8Array, client: boolean): Promise { + if (secret.byteLength !== SECRET_SIZE) throw new Error("route key secret must be 32 bytes"); + // The domain, then the prover's side of the session (0 dialed, 1 accepted), then the secret. + const message = new Uint8Array(DOMAIN.byteLength + 1 + SECRET_SIZE); + message.set(DOMAIN); + message[DOMAIN.byteLength] = client ? 0 : 1; + message.set(secret, DOMAIN.byteLength + 1); + + const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", key.publicKey)); + const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key.privateKey, message)); + const proof = new Uint8Array(publicKey.byteLength + signature.byteLength); + proof.set(publicKey); + proof.set(signature, publicKey.byteLength); + return proof; +} diff --git a/js/net/src/lite/setup.test.ts b/js/net/src/lite/setup.test.ts index 4cd56920cc2..361925a0021 100644 --- a/js/net/src/lite/setup.test.ts +++ b/js/net/src/lite/setup.test.ts @@ -231,6 +231,22 @@ test("SETUP refuses an empty token", async () => { await expect(Setup.decode(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07)).rejects.toThrow(); }); +test("SETUP route key round-trips on lite-07 only, at exactly 96 bytes", async () => { + const proof = new Uint8Array(96).fill(7); + const msg = new Setup({ routeKey: proof }); + const wire = await bytes((w) => msg.encode(w, Version.DRAFT_07), Version.DRAFT_07); + const got = await Setup.decode(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07); + expect(got.routeKey).toEqual(proof); + + await expect(bytes((w) => msg.encode(w, Version.DRAFT_06), Version.DRAFT_06)).rejects.toThrow(); + // An older version ignores the parameter like any unknown one. + expect((await decodeParam(0x8n, proof, Version.DRAFT_06)).routeKey).toBeUndefined(); + + const short = new Setup({ routeKey: new Uint8Array(95) }); + await expect(bytes((w) => short.encode(w, Version.DRAFT_07), Version.DRAFT_07)).rejects.toThrow(); + await expect(decodeParam(0x8n, new Uint8Array(95), Version.DRAFT_07)).rejects.toThrow(); +}); + test("older SETUP versions ignore the unknown Implementation parameter before parsing it", async () => { for (const version of [Version.DRAFT_05, Version.DRAFT_06]) { for (const value of [new TextEncoder().encode("x/1"), new Uint8Array([0xff])]) { diff --git a/js/net/src/lite/setup.ts b/js/net/src/lite/setup.ts index 30694c8ddf5..f5c30b80c2c 100644 --- a/js/net/src/lite/setup.ts +++ b/js/net/src/lite/setup.ts @@ -9,7 +9,7 @@ import { type Hop, HopSchema, wireHop } from "../hop.ts"; import { encodeVarint, Reader, type Writer } from "../stream.ts"; import { decodeUtf8 } from "../util/utf8.ts"; import * as Message from "./message.ts"; -import { hasAuth, hasImplementation, hasSetupStream, type Version } from "./version.ts"; +import { hasAuth, hasImplementation, hasRouteKey, hasSetupStream, type Version } from "./version.ts"; const clientParameters = new WeakSet(); @@ -30,6 +30,10 @@ const PARAM_HOP = 0x5n; const PARAM_IMPLEMENTATION = 0x6n; /** Setup Parameter id for the client's connection credential (client-only, lite-07+). */ const PARAM_TOKEN = 0x7n; +/** Setup Parameter id for the sender's route key proof (lite-07+). */ +const PARAM_ROUTE_KEY = 0x8n; +/** A route key proof's size: an Ed25519 public key, then its signature. */ +const ROUTE_KEY_PROOF_SIZE = 96; /** SETUP only carries negotiation parameters, so it gets a far smaller cap than other messages. */ const MAX_SETUP_SIZE = 0xffff; @@ -179,6 +183,8 @@ export interface SetupProps { token?: Uint8Array; /** See {@link Setup.implementation}. Omitted by default. */ implementation?: string; + /** See {@link Setup.routeKey}. Omitted by default. */ + routeKey?: Uint8Array; } /** @@ -228,13 +234,20 @@ export class Setup { */ implementation?: string; - constructor({ probe, path, role, hop, token, implementation }: SetupProps = {}) { + /** + * Proof that the sender holds its route key, bound to this session (lite-07+): the + * Ed25519 public key, then its signature over keying material exported from the session. + */ + routeKey?: Uint8Array; + + constructor({ probe, path, role, hop, token, implementation, routeKey }: SetupProps = {}) { this.probe = probe ?? ProbeLevel.None; this.path = path; this.role = role ?? Role.Both; this.hop = hop; this.token = token; this.implementation = implementation; + this.routeKey = routeKey; } static #guard(version: Version) { @@ -267,6 +280,11 @@ export class Setup { if (this.token.byteLength === 0) throw new Error("setup token cannot be empty"); params.setBytes(PARAM_TOKEN, this.token); } + if (this.routeKey !== undefined) { + if (!hasRouteKey(version)) throw new Error("setup route key not supported for this version"); + if (this.routeKey.byteLength !== ROUTE_KEY_PROOF_SIZE) throw new Error("setup route key must be 96 bytes"); + params.setBytes(PARAM_ROUTE_KEY, this.routeKey); + } await params.encode(w); } @@ -295,7 +313,13 @@ export class Setup { const implementationBytes = hasImplementation(version) ? params.getBytes(PARAM_IMPLEMENTATION) : undefined; const implementation = implementationBytes === undefined ? undefined : decodeUtf8(implementationBytes); - const setup = new Setup({ probe, path, role, hop, token, implementation }); + // Kept but not verified: nothing here files routes under the sender's key. + const routeKey = hasRouteKey(version) ? params.getBytes(PARAM_ROUTE_KEY) : undefined; + if (routeKey !== undefined && routeKey.byteLength !== ROUTE_KEY_PROOF_SIZE) { + throw new Error("setup route key must be 96 bytes"); + } + + const setup = new Setup({ probe, path, role, hop, token, implementation, routeKey }); if (pathBytes !== undefined || roleCode !== undefined || token !== undefined) clientParameters.add(setup); return setup; } diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index a9004ee48f2..3dbc4bc20e2 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -17,7 +17,7 @@ import { TimestampMismatch, } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; -import { HopSchema, isAnonymous, MAX_HOPS, Route, UNKNOWN_HOP } from "../hop.ts"; +import { HopSchema, isAnonymous, MAX_HOPS, RouteInfo, UNKNOWN_HOP } from "../hop.ts"; import * as Instance from "../instance.ts"; import { hooks } from "../internal.ts"; import { GRACE } from "../ledger.ts"; @@ -42,7 +42,7 @@ import { SubscribeStart, SubscribeUpdate, } from "./subscribe.ts"; -import { Minted, SUBSCRIBE_SETUP_TIMEOUT_MS, Subscriber } from "./subscriber.ts"; +import { Minted, Subscriber } from "./subscriber.ts"; import { Track, TrackInfo } from "./track.ts"; import { Version, versionName } from "./version.ts"; @@ -201,8 +201,8 @@ test("an announce consumer closed while its stream opens sends nothing on it", a }); test("a local empty hop chain is not anonymous", () => { - expect(isAnonymous(Route.default)).toBe(false); - expect(isAnonymous({ hops: [UNKNOWN_HOP], cost: Route.default.cost })).toBe(true); + expect(isAnonymous(RouteInfo.default)).toBe(false); + expect(isAnonymous({ hops: [UNKNOWN_HOP], cost: RouteInfo.default.cost })).toBe(true); }); test("a max-length chain plus withheld responder is dropped", async () => { @@ -1182,13 +1182,14 @@ test.each([ await stuck.aborted; }); -// A setup that outlived its deadline is over: its TRACK stream is reset, along with the SUBSCRIBE -// sent beside it, and the subscription is not registered again. +// An unanswered setup waits as long as it is wanted. Once its last subscriber leaves it is over: its +// TRACK stream is reset along with the SUBSCRIBE sent beside it, +// and the subscription is not registered again. test.each([ ["lite-05 subscribe waiting on the TRACK_INFO", Version.DRAFT_05], ["lite-06 subscribe waiting on the TRACK_INFO", Version.DRAFT_06], ["lite-07 subscribe waiting on the TRACK_INFO", Version.DRAFT_07], -] as const)("a %s that times out leaves nothing behind", async (_, version) => { +] as const)("a %s abandoned before its answer leaves nothing behind", async (_, version) => { const clock = new Manual(); const installed = clock.install(); const warn = spyOn(console, "warn").mockImplementation(() => {}); @@ -1199,8 +1200,9 @@ test.each([ await opened(0); await streams[0].reading; - await clock.advance(Nano.fromMilli(Milli(SUBSCRIBE_SETUP_TIMEOUT_MS))); - await track.closed; + await clock.advance(Nano.fromMilli(Milli(3_600_000))); + expect(track.closed.peek()).toBeUndefined(); + track.close(); await streams[0].aborted; await streams[1].aborted; @@ -1235,13 +1237,10 @@ test("a lite subscribe waiting on a stream slot for the SUBSCRIBE outlasts the d const track = subscriber.consume(Path.from("room")).track("video").subscribe(); await opened(0); await streams[0].reading; - // TRACK_INFO lands halfway, so the SUBSCRIBE open's own deadline is still ahead when the - // setup deadline fires. - await clock.advance(Nano.fromMilli(Milli(SUBSCRIBE_SETUP_TIMEOUT_MS / 2))); await answerTrackInfo(streams[0]); await opened(1); - await clock.advance(Nano.fromMilli(Milli(3 * SUBSCRIBE_SETUP_TIMEOUT_MS))); + await clock.advance(Nano.fromMilli(Milli(3_600_000))); expect(track.closed.peek()).toBeUndefined(); streams[1].release(); @@ -1310,9 +1309,9 @@ test("a lite subscribe whose readers leave while it waits for a stream gives up } }); -// A SUBSCRIBE write blocked on flow control may never let the setup settle, so the deadline +// A SUBSCRIBE write blocked on flow control may never let the setup settle, so abandonment // lets go of the held TRACK stream without waiting for it. -test("a lite subscribe whose SUBSCRIBE write stalls past the deadline closes its TRACK stream", async () => { +test("a lite subscribe abandoned while its SUBSCRIBE write stalls closes its TRACK stream", async () => { const clock = new Manual(); const installed = clock.install(); const warn = spyOn(console, "warn").mockImplementation(() => {}); @@ -1325,9 +1324,10 @@ test("a lite subscribe whose SUBSCRIBE write stalls past the deadline closes its await answerTrackInfo(streams[0]); await (await opened(1)).writing; - await clock.advance(Nano.fromMilli(Milli(SUBSCRIBE_SETUP_TIMEOUT_MS))); - await track.closed; - await streams[0].aborted; + await clock.advance(Nano.fromMilli(Milli(3_600_000))); + expect(track.closed.peek()).toBeUndefined(); + track.close(); + await streams[0].finished; } finally { subscriber.close(); warn.mockRestore(); @@ -2352,7 +2352,7 @@ test.each( const runResponses = internal.runResponses.bind(internal); const responses = spyOn(internal, "runResponses").mockImplementation((stream, entry) => { counters.push( - ...[entry.track.subscription, entry.track.demand().wanted, hooks.producerLargest(entry.track)].map( + ...[hooks.requested(entry.track), entry.track.demand().wanted, hooks.producerLargest(entry.track)].map( countChanges, ), ); @@ -2881,36 +2881,40 @@ test("announce init is the initial set", async () => { subscriber.close(); }); -// The FETCH shares the TRACK's deadline, which only runs while neither stream waits to open: -// waiting for credit is waiting its turn, so only an unanswered request fails. -test.each(["answer", "credit"] as const)("FETCH shares the TRACK deadline while waiting for %s", async (stage) => { +// A FETCH waits for its answer, or a slot a transport queued it for, until its caller leaves. +test.each(["answer", "credit"] as const)("FETCH waiting for %s ends with its caller", async (stage) => { const clock = new Manual(); const installed = clock.install(); const { quic, streams, opened } = fakeSession(stage === "credit" ? [1] : []); const subscriber = new Subscriber(quic, Version.DRAFT_07, HopSchema.parse(1n)); + const cancel = new AbortController(); let failure: unknown; - const fetch = subscriber.fetchGroup(Path.from("room"), "video", 0n).catch((err: unknown) => { - failure = err; - }); + const fetch = subscriber + .fetchGroup(Path.from("room"), "video", 0n, { signal: cancel.signal }) + .catch((err: unknown) => { + failure = err; + }); try { await opened(0); await streams[0].reading; - await clock.advance(Nano.fromMilli(Milli(5_000))); await answerTrackInfo(streams[0], Version.DRAFT_07); await opened(1); - if (stage === "credit") { - await clock.advance(Nano.fromMilli(Milli(30_000))); - expect(failure).toBeUndefined(); - streams[1].release(); - } - await streams[1].reading; - // Opened alongside the TRACK, the FETCH shares its budget; one that waited for credit - // gets a whole budget from its open. - await clock.advance(Nano.fromMilli(Milli(stage === "credit" ? 9_999 : 4_999))); + if (stage === "answer") await streams[1].reading; + await clock.advance(Nano.fromMilli(Milli(3_600_000))); expect(failure).toBeUndefined(); - await clock.advance(Nano.fromMilli(Milli(1))); + const reason = new Error("left"); + cancel.abort(reason); await fetch; - expect((failure as StreamError).code).toBe(StreamCode.ControlTimeout); + expect(failure).toBe(reason); + // Credit granted once the caller has left can't send the FETCH. + await clock.advance(Nano.zero); + if (stage === "credit") streams[1].release(); + if (stage === "credit") { + await drainReactions(); + expect(streams[1].written).toEqual([]); + } else { + await streams[1].aborted; + } } finally { subscriber.close(); installed.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 7c6fb03d36c..5a2eb95690b 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1,4 +1,4 @@ -import { Milli } from "@moq/clock"; +import { Milli, yieldNow } from "@moq/clock"; import { type Dispose, Effect, type GetPromise, type Getter, race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; import type { Grant } from "../auth.ts"; @@ -21,7 +21,7 @@ import { unauthorized, } from "../error.ts"; import * as netGroup from "../group.ts"; -import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; +import { Cost, type Hop, MAX_HOPS, type RouteInfo, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import * as Instance from "../instance.ts"; import { hiddenBelow, hooks, maxDelayOf, minDelayOf, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import type { Lister } from "../listing.ts"; @@ -33,7 +33,7 @@ import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; import { closeUnwanted } from "../util/announce.ts"; -import { Deadline, REQUEST_TIMEOUT_MS } from "../util/deadline.ts"; +import { Deadline } from "../util/deadline.ts"; import { abandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; import { min, UNBOUNDED } from "../util/sequence.ts"; @@ -96,16 +96,17 @@ import { // and removals of one name never reaches the announces cap: yield so it cannot starve the host. const listingBudget = new Budget(); const datagramBudget = new Budget(); - -// Request streams refuse exhausted peer credit, with one deadline covering create and answer. -export const SUBSCRIBE_SETUP_TIMEOUT_MS = REQUEST_TIMEOUT_MS; +// The datagrams held for unanswered subscriptions at once, as many as a Rust pass holds: past it, +// one is dropped and counted rather than stalling the receive queue, which would hold up other +// tracks' datagrams behind their ends. +const HELD_DATAGRAMS = 32; // Wire ceiling (2^62-1). A draining session stamps it on every live route so any other // candidate outranks it, while the route stays selectable as the last path. Matches Rust // Cost::DRAIN: cost is the whole mechanism, not a separate state. const DRAIN_COST: Cost = 2n ** 62n - 1n; -// What a subscription has set up so far, so a revocation (or a timeout) can reach it at +// What a subscription has set up so far, so a revocation (or abandonment) can reach it at // any stage. interface SubscribeSetup { consumer?: broadcast.Consumer; @@ -113,7 +114,7 @@ interface SubscribeSetup { stream?: Stream; track?: Stream; producer?: track.Producer; - // Ends a setup still running once the deadline passed or the grant was revoked. + // Ends a setup still running once it failed, nobody wants it, or the grant was revoked. cancel: AbortController; // Set once our grant stops covering the broadcast. revoked?: Error; @@ -136,6 +137,9 @@ interface Consumed { interface DatagramEntry { producer: datagram.Producer; timescale?: Time.Timescale; + // Set once the publisher ended the track, while the datagrams received with the end route: + // a session close meanwhile finishes it rather than cutting it off. + ended?: boolean; } interface SubscribeEntry { @@ -247,6 +251,16 @@ export class Subscriber { #datagrams = new Map(); #legacyDatagrams = new Map(); #legacyRequests = new Map(); + // Lite-07 datagram requests sent and not yet answered. + #unanswered = new Set(); + // Datagrams for a subscription still awaiting its answer, in arrival order, routed a host task + // later while the rest go on. + #held: { subscribe: bigint; payload: Uint8Array }[] = []; + #heldRouted?: Promise; + // Set while the receive loop yields to its budget, which may leave datagrams queued. + #receiveYield?: Promise; + /** Datagrams received for a subscription with no datagram track; see `Stats.datagramsUnrouted`. */ + datagramsUnrouted = 0; // A SUBSCRIBE sent alongside its TRACK, until its TRACK_INFO lands: settles with the // entry, or `undefined` if it never does. #pending = new Map>(); @@ -409,7 +423,7 @@ export class Subscriber { // retract that one's state. type Advertisement = { live: boolean; - route: Route; + route: RouteInfo; captures: Path.Pattern[] | undefined; }; // Each holds one of the session's announce slots, reflected and hidden ones included. @@ -721,7 +735,7 @@ export class Subscriber { }); continue; } - const route: Route = { instance, epoch, hops: fullHops, cost: this.#cost(cost) }; + const route: RouteInfo = { instance, epoch, hops: fullHops, cost: this.#cost(cost) }; const captures = scopeCaptures(scope, path); if (!visible(path)) { advertised.set(path, { live: false, route, captures }); @@ -895,6 +909,7 @@ export class Subscriber { { version: this.version }, async (opened) => { stream = opened; + this.#unanswered.add(id); await opened.writer.u53(StreamId.Datagrams); await new Datagrams({ id, @@ -913,12 +928,17 @@ export class Subscriber { signal.throwIfAborted(); if (!stream) throw new Error("datagram stream was not opened"); producer = request.accept(answered); - this.#datagrams.set(id, { producer, timescale: answered.timescale }); + const route: DatagramEntry = { producer, timescale: answered.timescale }; + this.#datagrams.set(id, route); + this.#unanswered.delete(id); const opened = stream; const receiving = producer; - // The only thing following DATAGRAMS_INFO is the publisher's FIN. - const ended = opened.reader.done().then((done) => { + // The only thing following DATAGRAMS_INFO is the publisher's FIN. Its last datagram may + // arrive alongside, so the datagrams already received route first. + const ended = opened.reader.done().then(async (done) => { if (!done) throw new ProtocolViolation("data after DATAGRAMS_INFO"); + route.ended = true; + await this.#datagramsRouted(id); receiving.finish(); }); void ended.catch(() => {}); @@ -946,6 +966,7 @@ export class Subscriber { if (legacyKey !== undefined && this.#legacyRequests.get(legacyKey) === request) this.#legacyRequests.delete(legacyKey); grant?.(); + this.#unanswered.delete(id); this.#datagrams.delete(id); cancel.abort(new StreamError(StreamCode.Cancel)); stream?.reader.stop(StreamCode.Cancel); @@ -1184,10 +1205,10 @@ export class Subscriber { console.debug(`subscribe start: id=${id} broadcast=${broadcast} track=${request.name}`); - // Open the stream under a timeout. The stream handles flow back via `state` - // so the timeout path can close them if they finish opening after the deadline, - // and `cancel` ends a setup still running once the deadline passed, resetting - // its TRACK stream so a peer that never answers can't hold one per attempt. + // The publisher's application may hold its answer, so the setup waits for it until the + // last subscriber leaves, the broadcast goes, or the session ends. The stream handles + // flow back via `state` so a failed setup can close them even if they open later, and + // `cancel` ends a setup still running, resetting its TRACK stream. const retire = this.#retirer(state, valid); valid?.addEventListener("abort", retire); if (valid?.aborted) retire(); @@ -1206,6 +1227,7 @@ export class Subscriber { left.abort( new StreamError(StreamCode.Cancel, { message: "subscribe abandoned before it was accepted" }), ); + state.cancel.abort(left.signal.reason); } else { unwatch = Signal.race(demand.wanted, hooks.demandReleased(demand)).changed(watch); } @@ -1218,9 +1240,8 @@ export class Subscriber { console.debug(`subscribe ok: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { closeViolation(this.#quic, err); - // The setup outlived its deadline waiting for the first response: a control - // timeout, not content that arrived late. A revocation or the readers leaving says - // so instead. + // Local abandonment commits without yielding after the demand check. A revocation + // says so instead. const e = state.revoked ?? (left.signal.aborted ? left.signal.reason : await sessionCause(this.#quic, err)); state.cancel.abort(e); valid?.removeEventListener("abort", retire); @@ -1228,9 +1249,9 @@ export class Subscriber { this.#subscribes.delete(id); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); // Close the streams open now, since a write blocked on flow control may never let - // setup settle, and any that open after the timeout once it does. Cover both - // branches: setup may resolve late, or it may reject (e.g. encode/decode failure) - // after a stream is open. + // setup settle, and any that open later once it does. Cover both branches: setup + // may resolve late, or it may reject (e.g. encode/decode failure) after a stream + // is open. const leave = () => { state.stream?.abort(e); state.track?.close(); @@ -1373,6 +1394,11 @@ export class Subscriber { entry.held?.close(); entry.datagramDemand?.(); if (entry.datagrams) { + // The stream track's end is its datagrams' end: those received with it route first. + if (entry.end !== undefined && !(hooks.trackReleased(producer).peek() instanceof Error)) { + entry.datagrams.ended = true; + await this.#datagramsRouted(id); + } const closed = hooks.trackReleased(producer).peek(); if (closed instanceof Error) entry.datagrams.producer.abort(closed); else if (entry.end !== undefined) entry.datagrams.producer.finish(); @@ -1389,7 +1415,7 @@ export class Subscriber { // Open the subscribe stream and accept the request (so the application's track.Subscriber // resolves and incoming groups have a producer to write into) with the track's immutable // properties. `state.stream` is populated as soon as it opens, and `state.track` once - // answered, so the caller can clean them up on timeout even before this promise settles. + // answered, so the caller can clean them up once it gives up, even before this promise settles. // // On lite-05+ the properties come from a TRACK stream, held open until the SUBSCRIBE is // answered, and the SUBSCRIBE goes out alongside it rather than a round trip after: it is @@ -1452,7 +1478,7 @@ export class Subscriber { // Either failing fails the subscription: a reset TRACK rejects the request, so the // origin can try another route, and the caller resets the SUBSCRIBE. const [answered, stream] = await Promise.all([info, sending]); - // The deadline passed as TRACK_INFO landed: the request is already rejected, so don't + // The setup ended as TRACK_INFO landed: the request is already rejected, so don't // accept it again. state.cancel.signal.throwIfAborted(); const model = this.#toModelInfo(answered.info); @@ -1757,9 +1783,9 @@ export class Subscriber { void group.closed.then(() => disposeGrant?.()); if (this.#denied(broadcast)) throw refused; - // Lite has no FETCH_OK, so a publisher that never answers would hold the setup forever. - // Subscriber.close() closing the group releases every caller at any stage, and resets - // the streams the setup opened. + // Lite has no FETCH_OK, and the publisher's application may hold its answer. Every + // caller leaving, or Subscriber.close() closing the group, releases the setup at any + // stage and resets the streams it opened. const deadline = new Deadline(); const setup = deadline.wait( this.#fetchSetup(broadcast, consumed, track, sequence, priority, group, deadline, maxDelay), @@ -2141,7 +2167,7 @@ export class Subscriber { #covered(entry: SubscribeEntry): boolean { // Without SUBSCRIBE_FINISH (older drafts) nothing says which groups are owed. if (entry.end === undefined) return false; - const ranges = wireRanges(entry.track.subscription.peek()?.ranges ?? [{}]); + const ranges = wireRanges(hooks.requested(entry.track).peek()?.ranges ?? [{}]); const end = min(entry.end, wireSpan(ranges).end ?? entry.end); const floor = rangesStart(ranges); // Lite-07 answers with SUBSCRIBE_OK once the track ends, unless nothing was asked below the @@ -2218,10 +2244,10 @@ export class Subscriber { wanted !== sentWanted ? wanted : undefined; - const current = track.subscription.peek(); + const current = hooks.requested(track).peek(); if (current === undefined || (refresh === undefined && this.#sameSubscription(current, lastSent))) { // Each wake releases the quiet signals too, including when termination wins. - const next = await race([Signal.race(track.subscription, demand.wanted, edge), stopped]); + const next = await race([Signal.race(hooks.requested(track), demand.wanted, edge), stopped]); if (next === null) return; continue; } @@ -2386,7 +2412,11 @@ export class Subscriber { try { for (;;) { const yielded = datagramBudget.poll(); - if (yielded !== undefined) await yielded; + if (yielded !== undefined) { + this.#receiveYield = yielded; + await yielded; + this.#receiveYield = undefined; + } const { value, done } = await reader.read(); if (done) break; if (!value) continue; @@ -2410,24 +2440,92 @@ export class Subscriber { } } - // Decode one datagram body and hand it to the matching subscription's producer. Drops the - // datagram (best-effort) if the subscription is unknown or closed. + // Decode one datagram body and hand it to the datagram track it was sent for. A publisher + // sends a track's first datagram right behind its answer, which the transport may hand over + // first: one for a subscription still awaiting its answer is held a host task, so an answer + // already received decodes before it, while other tracks' datagrams go on. One that still + // finds no track is dropped and counted. async #routeDatagram(payload: Uint8Array): Promise { if (payload.byteLength > DatagramMessage.MAX_SIZE) return; + let unrouted = await this.#deliverDatagram(payload, true); + if (unrouted === undefined) return; + // Its track may have registered while the decode awaited. + if (!this.#isHeld(unrouted) && this.#datagrams.has(unrouted)) + unrouted = await this.#deliverDatagram(payload, true); + if (unrouted === undefined) return; + const awaiting = this.#isHeld(unrouted) || this.#unanswered.has(unrouted) || this.#pending.has(unrouted); + if (awaiting && this.#held.length < HELD_DATAGRAMS) { + this.#held.push({ subscribe: unrouted, payload }); + this.#heldRouted ??= this.#routeHeld(); + return; + } + this.#dropUnrouted(unrouted); + } + + // Wait for the datagrams received for `subscribe` so far to route: a host task, then until the + // receive loop reads the transport's queue empty rather than yielding to its budget, and those + // held for its answer. A peer keeping the queue full delays the end, holding nothing more. + async #datagramsRouted(subscribe: bigint): Promise { + await yieldNow(); + for (;;) { + const receiveYield = this.#receiveYield; + if (receiveYield !== undefined) { + await receiveYield; + // The loop reads on in microtasks, until the queue empties or its next yield. + await yieldNow(); + } else if (this.#isHeld(subscribe)) { + // A held datagram keeps its routing pass running. + await this.#heldRouted; + } else return; + } + } + + // Whether a datagram for `subscribe` is held, which later ones wait behind to keep its order. + #isHeld(subscribe: bigint): boolean { + return this.#held.some((held) => held.subscribe === subscribe); + } + + // A host task later, route the datagrams held until now, then any held meanwhile. + async #routeHeld(): Promise { + await yieldNow(); + for (let count = this.#held.length; count > 0; count--) { + const [{ payload }] = this.#held; + try { + const unrouted = await this.#deliverDatagram(payload, false); + if (unrouted !== undefined) this.#dropUnrouted(unrouted); + } catch (err: unknown) { + console.debug(`dropping datagram: ${reason(err)}`); + } + this.#held.shift(); + } + this.#heldRouted = this.#held.length > 0 ? this.#routeHeld() : undefined; + } + + #dropUnrouted(subscribe: bigint): void { + this.datagramsUnrouted++; + console.debug(`dropping datagram for a subscription with no datagram track: id=${subscribe}`); + } + + // Deliver one datagram body, returning its Subscribe ID if that subscription has no datagram + // track, or, `behindHeld`, has a datagram held. + async #deliverDatagram(payload: Uint8Array, behindHeld: boolean): Promise { // Read with the entry it was decoded with: one registered while the decode awaited would - // read a datagram parsed with the defaults. An unknown subscription's datagram, or one - // whose TRACK_INFO had not landed, which a datagram cannot wait for, is dropped below. + // read a datagram parsed with the defaults. let entry: DatagramEntry | undefined; const dg = await DatagramMessage.decode(payload, this.version, (id) => { entry = this.#datagrams.get(id); return { timed: entry?.timescale !== undefined }; }); - if (!dg || !entry || this.#datagrams.get(dg.subscribe) !== entry) return; + // Padding, which names no subscription. + if (!dg) return undefined; + if (!entry || this.#datagrams.get(dg.subscribe) !== entry) return dg.subscribe; + if (behindHeld && this.#isHeld(dg.subscribe)) return dg.subscribe; const timestamp = dg.timestamp === undefined || entry.timescale === undefined ? undefined : new Time.Timestamp(dg.timestamp, entry.timescale); entry.producer.insert({ sequence: dg.sequence, timestamp, payload: dg.payload }); + return undefined; } /** @@ -2557,7 +2655,7 @@ export class Subscriber { /** * Ends every subscribed track, fetch, and setup exchange with `err`, the session's end, which * defaults to a clean close: they were cut off rather than ended. A track whose publisher - * already ended it finishes instead. + * already ended it finishes instead, datagram tracks included. */ close(err: Error = new SessionError(SessionCode.Cancel)) { this.#closed.abort(err); @@ -2568,7 +2666,10 @@ export class Subscriber { } this.#subscribes.clear(); - for (const { producer } of this.#datagrams.values()) producer.abort(err); + for (const { producer, ended } of this.#datagrams.values()) { + if (ended) producer.finish(); + producer.abort(err); + } this.#datagrams.clear(); this.#legacyDatagrams.clear(); diff --git a/js/net/src/lite/track_stream.test.ts b/js/net/src/lite/track_stream.test.ts index cb396508eb3..3ef3a0341f4 100644 --- a/js/net/src/lite/track_stream.test.ts +++ b/js/net/src/lite/track_stream.test.ts @@ -117,9 +117,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; let producer: TrackProducer | undefined; const serving = (async () => { for (;;) { @@ -155,9 +154,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const closing = spyOn(pair.server, "close"); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); @@ -179,9 +177,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; let producer: TrackProducer | undefined; const serving = (async () => { for (;;) { @@ -227,9 +224,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producers: TrackProducer[] = []; const serving = (async () => { for (;;) { @@ -269,9 +265,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const producers: TrackProducer[] = []; const serving = (async () => { for (;;) { @@ -328,9 +323,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; // Answered by hand, once every requester has left. const requested = wireOf(broadcast).requested(); @@ -356,9 +350,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const requested = wireOf(broadcast).requested(); await requester(pair, publisher, kind); @@ -379,9 +372,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; // Answered by hand, after the first TRACK stream has left. const requested = wireOf(broadcast).requested(); @@ -410,9 +402,8 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver const pair = createMockTransportPair(ALPN_05); const origin = new OriginProducer(); const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const serving = (async () => { for (;;) { const request = await wireOf(broadcast).requested(); diff --git a/js/net/src/lite/tracks.test.ts b/js/net/src/lite/tracks.test.ts index 346abbbe2f7..cd3beebc5ab 100644 --- a/js/net/src/lite/tracks.test.ts +++ b/js/net/src/lite/tracks.test.ts @@ -350,9 +350,8 @@ function publisher(origin: OriginProducer) { test("data after a TRACKS request closes the session", async () => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("a"); const { list, closing } = publisher(origin); const listing = await list("room"); @@ -366,9 +365,8 @@ test("data after a TRACKS request closes the session", async () => { test("a publisher lists its broadcast's own tracks, never one served on request", async () => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("a", { priority: 5 }); // Served on request: answered when asked, never listed. void wireOf(broadcast) @@ -389,9 +387,8 @@ test("a publisher lists its broadcast's own tracks, never one served on request" test("a newer listing of a broadcast on one session replaces the older", async () => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("a"); const { list } = publisher(origin); const older = await list("room"); @@ -408,7 +405,7 @@ test("a newer listing of a broadcast on one session replaces the older", async ( test("a path served through a covering prefix route is NOT_FOUND", async () => { const origin = new OriginProducer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); void (async () => { for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); @@ -425,7 +422,7 @@ test("a path served through a covering prefix route is NOT_FOUND", async () => { test("a local read through a covering prefix route is NOT_FOUND, and through its exact route lists", async () => { const origin = new OriginProducer(); for (const prefix of ["room", "room/bob"]) { - const dynamic = origin.requestsRoute(Path.from(prefix)); + const dynamic = origin.routePrefix(Path.from(prefix)); void (async () => { for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); @@ -461,9 +458,8 @@ test("a listing over a session carries each track's info, end to end", async () connect({ url, transport: pair.client }), accept({ transport: pair.server, url, publish: origin.consume() }), ]); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("a", { timescale: Timescale.MILLI, maxAge: Milli(3000), @@ -497,10 +493,13 @@ test("an instance-pinned front lists its serving route's tracks", async () => { connect({ url, transport: pair.client, consume: clientOrigin }), accept({ transport: pair.server, url, publish: serverOrigin.consume() }), ]); - const broadcastPublication = serverOrigin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = serverOrigin.route(Path.from("room"), { + hidden: true, + instance: Instance.parse("01900000-0000-7000-8000-000000000001"), + }); + const broadcast = broadcastRoute.broadcast; broadcast.createTrack("a"); - broadcastPublication.announce({ instance: Instance.parse("01900000-0000-7000-8000-000000000001") }); + broadcastRoute.announce(); const reader = clientOrigin.consume(); const announced = reader.routes(); @@ -525,7 +524,7 @@ test("an instance-pinned front lists its serving route's tracks", async () => { test("a front pinned to a named instance served through a covering prefix route is NOT_FOUND", async () => { const origin = new OriginProducer(); - const dynamic = origin.requestsRoute(Path.from("room"), { instance: Instance.parse("named") }); + const dynamic = origin.routePrefix(Path.from("room"), { instance: Instance.parse("named") }); void (async () => { for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); @@ -551,9 +550,8 @@ test("lite-06 cannot list over a session", async () => { connect({ url, transport: pair.client }), accept({ transport: pair.server, url, publish: origin.consume() }), ]); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("room")); + const broadcast = broadcastRoute.broadcast; const tracks = wireOf(client).consume(Path.from("room")).tracks(); expect(await next(tracks)).toMatch(/requires moq-lite-07/); broadcast.close(); diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index 91c3310693d..228e7266344 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -657,6 +657,22 @@ export function hasSynced(version: Version): boolean { } } +/** Whether SETUP may carry a route key proof, added in lite-07. */ +export function hasRouteKey(version: Version): boolean { + // Explicitly list older versions so future versions default to carrying it. + switch (version) { + case Version.DRAFT_01: + case Version.DRAFT_02: + case Version.DRAFT_03: + case Version.DRAFT_04: + case Version.DRAFT_05: + case Version.DRAFT_06: + return false; + default: + return true; + } +} + /** Whether SETUP recognizes the Implementation parameter, added in lite-07. */ export function hasImplementation(version: Version): boolean { switch (version) { diff --git a/js/net/src/lite/widened_max_age.test.ts b/js/net/src/lite/widened_max_age.test.ts index 6bc779b4cb9..9ef760cfb96 100644 --- a/js/net/src/lite/widened_max_age.test.ts +++ b/js/net/src/lite/widened_max_age.test.ts @@ -28,8 +28,7 @@ async function received(reader: TrackSubscriber, count: number): Promise { const pair = createMockTransportPair(ALPN_07_WIP); const origin = new OriginProducer(); - const publication = origin.createBroadcast(Path.from("room")); - publication.announce(); + const publication = origin.route(Path.from("room")); const broadcast = publication.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); // One group per second, so a budget picks how far back a reader reaches. diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index f78d2df0efa..4ddba1863dc 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -139,9 +139,8 @@ async function backlog(protocol: string, order: Order, update?: Order) { const origin = new OriginProducer(); const { client, server, transport } = await session(protocol, origin); - const broadcastPublication = origin.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); const groups = [0, 1, 2].map(() => writeGroup(video)); @@ -193,9 +192,8 @@ test.each(["ascending", "descending"] as const)("ietf serves GROUP_ORDER %s", as const origin = new OriginProducer(); const { client, server, transport } = await session(Ietf.ALPN.DRAFT_18, origin); - const broadcastPublication = origin.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); const remote = wireOf(client).consume(Path.from("test")); @@ -261,9 +259,8 @@ test("ietf keeps a closing group stream ranked until it settles", async () => { const { client, server, transport } = await session(Ietf.ALPN.DRAFT_18, origin); const fins = holdFins(transport); - const broadcastPublication = origin.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); const remote = wireOf(client).consume(Path.from("test")); @@ -302,9 +299,8 @@ test("a shared track aggregates descending and serves each subscriber in its own session(Lite.ALPN_07_WIP, origin), ]); - const broadcastPublication = origin.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); const ascendingRemote = wireOf(ascending.client).consume(Path.from("test")); diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index 018a10d7679..a3511747e5d 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -27,7 +27,7 @@ test.each(["lite", "ietf", "fetch", "track"] as const)( async (kind) => { const pair = createMockTransportPair(); const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const publisher = kind === "ietf" ? new IetfPublisher({ @@ -106,7 +106,7 @@ test.each(["lite", "ietf", "fetch", "track"] as const)( test("last cancellation finishes the internal wait and discards a late consumer", async () => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const hold = new AbortController(); const wait = wireOf(origin.consume()).demand(path, undefined, hold.signal); @@ -134,7 +134,7 @@ test("last cancellation finishes the internal wait and discards a late consumer" expect(source.closed.peek()).toBeUndefined(); fresh.serve(source); await settle(); - expect(next.active.peek()?.instance).toBe(dynamic.route.instance); + expect(next.active.peek()?.instance).toBe(dynamic.instance); next.close(); keeper.close(); source.close(); @@ -149,7 +149,7 @@ test.each([true, false])( "shared local/remote demand is withdrawn whichever leaves last (local first: %s)", async (localFirst) => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const hold = new AbortController(); const wait = wireOf(origin.consume()) @@ -193,13 +193,13 @@ test.each([ ] as const)("cancelling one waiter preserves a second %s requester across %i routes", async (other, routes) => { const origin = new Producer(); const prefix = Path.from("room"); - const dynamics = Array.from({ length: routes }, (_, i) => origin.requestsRoute(prefix, { cost: BigInt(i) })); + const dynamics = Array.from({ length: routes }, (_, i) => origin.routePrefix(prefix, { cost: BigInt(i) })); const hold = new AbortController(); const wire = wireOf(origin.consume()); const first = wire.demand(path, undefined, hold.signal).catch(() => undefined); // With competing routes, re-price the cancelled waiter's route so the survivor pends on another. const cancelled = dynamics[0]; - if (routes > 1) cancelled.updateRoute({ ...cancelled.route, cost: BigInt(routes) }); + if (routes > 1) cancelled.setCost(BigInt(routes)); const requests = dynamics[routes > 1 ? 1 : 0].requested(); const second = other === "peer" ? wire.demand(path) : undefined; const local = other === "local" ? origin.request(path) : undefined; @@ -212,7 +212,7 @@ test.each([ request.serve(source); await settle(); if (other === "peer") expect((await second)?.closed).toBe(source.closed); - else expect(local?.active.peek()?.instance).toBe(dynamics[routes > 1 ? 1 : 0].route.instance); + else expect(local?.active.peek()?.instance).toBe(dynamics[routes > 1 ? 1 : 0].instance); } finally { local?.close(); origin.close(); @@ -223,7 +223,7 @@ test.each([ test("an already cancelled signal never enqueues or subscribes to abort", async () => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const hold = new AbortController(); hold.abort(); @@ -245,7 +245,7 @@ test.each(["accept", "reject", "cancel"] as const)( "%s releases the abort listener without closing a shared producer", async (action) => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const source = new Broadcast(); const keeper = source.consume(); @@ -281,12 +281,12 @@ test.each(["accept", "reject", "cancel"] as const)( test("a pending local request changing routes withdraws the old route's work", async () => { const origin = new Producer(); - const first = origin.requestsRoute(Path.from("room"), { cost: 5n }); + const first = origin.routePrefix(Path.from("room"), { cost: 5n }); const a = first.requested(); const local = origin.request(path); const old = (await a.next()).value; if (!old) throw new Error("expected a pending request"); - const second = origin.requestsRoute(Path.from("room"), { cost: 1n }); + const second = origin.routePrefix(Path.from("room"), { cost: 1n }); const b = second.requested(); const fresh = (await b.next()).value; if (!fresh) throw new Error("expected a pending request"); @@ -299,7 +299,7 @@ test("a pending local request changing routes withdraws the old route's work", a expect(close).toHaveBeenCalledTimes(1); fresh.serve(source); await settle(); - expect(local.active.peek()?.instance).toBe(second.route.instance); + expect(local.active.peek()?.instance).toBe(second.instance); } finally { close.mockRestore(); local.close(); @@ -314,7 +314,7 @@ test("a pending local request changing routes withdraws the old route's work", a test("cancelling an old instance waiter does not decrement a new instance's demand", async () => { const origin = new Producer(); const instance = Instance.random(); - const dynamic = origin.requestsRoute(Path.from("room"), { instance }); + const dynamic = origin.routePrefix(Path.from("room"), { instance }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const hold = new AbortController(); @@ -322,7 +322,7 @@ test("cancelling an old instance waiter does not decrement a new instance's dema const old = (await requests.next()).value; if (!old) throw new Error("expected old request"); const next = Instance.random(); - dynamic.updateRoute({ instance: next }); + wireOf(dynamic).update({ instance: next }); const after = wire.demand(path, next); hold.abort(); await before; @@ -346,8 +346,8 @@ test.each(["answer", "release"] as const)( const origin = new Producer(); const instance = Instance.random(); const prefix = Path.from("room"); - const a = origin.requestsRoute(prefix, { instance, cost: 1n }); - const b = origin.requestsRoute(prefix, { instance, cost: 5n }); + const a = origin.routePrefix(prefix, { instance, cost: 1n }); + const b = origin.routePrefix(prefix, { instance, cost: 5n }); const ar = a.requested(); const br = b.requested(); const held = origin.request(path); @@ -355,7 +355,7 @@ test.each(["answer", "release"] as const)( (await ar.next()).value.serve(source); await settle(); // Another instance wins new requests. The old handle stays on its own instance. - const c = origin.requestsRoute(prefix, { instance: Instance.random(), cost: 0n }); + const c = origin.routePrefix(prefix, { instance: Instance.random(), cost: 0n }); a.close(); await settle(); const pending = (await br.next()).value; @@ -407,8 +407,8 @@ test.each([0, 1])( // Each instance undercuts the last, so it wins the path for the request made now. const instance = Instance.parse(`instance-${i + 1}`); const cost = BigInt(4 - 2 * i); - const primary = origin.requestsRoute(prefix, { instance, cost }); - const standby = origin.requestsRoute(prefix, { instance, cost: cost + 1n }); + const primary = origin.routePrefix(prefix, { instance, cost }); + const standby = origin.routePrefix(prefix, { instance, cost: cost + 1n }); const ar = primary.requested(); const br = standby.requested(); const held = origin.request(path); @@ -418,7 +418,7 @@ test.each([0, 1])( await settle(); entries.push({ primary, ar, br, held }); } - origin.requestsRoute(prefix, { instance: Instance.parse("instance-3"), cost: 0n }); + origin.routePrefix(prefix, { instance: Instance.parse("instance-3"), cost: 0n }); const pending = []; for (const entry of entries) { entry.primary.close(); @@ -459,7 +459,7 @@ test.each([0, 1])( test("a normal FETCH request FIN keeps unanswered origin demand alive", async () => { const pair = createMockTransportPair(); const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const publisher = new LitePublisher(pair.server, LiteVersion.DRAFT_06, randomHop(), origin.consume()); let input!: ReadableStreamDefaultController; @@ -500,7 +500,7 @@ test.each(["accept", "reject"] as const)( "cancellation racing %s releases only the cancelled waiter", async (answer) => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const hold = new AbortController(); @@ -529,7 +529,7 @@ test.each(["accept", "reject"] as const)( test.each([true, false])("last waiter cancellation and accept in one turn (cancel first: %s)", async (cancelFirst) => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const hold = new AbortController(); @@ -570,7 +570,7 @@ test.each([true, false])("last waiter cancellation and accept in one turn (cance test.each([true, false])("local demand survives same-turn cancellation only when rejoined: %s", async (rejoin) => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const local = origin.request(path); const hold = new AbortController(); @@ -592,7 +592,7 @@ test.each([true, false])("local demand survives same-turn cancellation only when expect(close).toHaveBeenCalledTimes(rejoin ? 0 : 1); if (replacement) { await settle(); - expect(replacement.active.peek()?.instance).toBe(dynamic.route.instance); + expect(replacement.active.peek()?.instance).toBe(dynamic.instance); } else { const fresh = origin.request(path); const next = (await requests.next()).value; @@ -600,7 +600,7 @@ test.each([true, false])("local demand survives same-turn cancellation only when if (!next) throw new Error("expected fresh request"); next.serve(source); await settle(); - expect(fresh.active.peek()?.instance).toBe(dynamic.route.instance); + expect(fresh.active.peek()?.instance).toBe(dynamic.instance); fresh.close(); } } finally { @@ -616,7 +616,7 @@ test.each([true, false])("local demand survives same-turn cancellation only when test("queued cancellation preserves order, replacement identity, and empty-queue wakeup", async () => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const holds = Array.from({ length: 4 }, () => new AbortController()); @@ -642,7 +642,7 @@ test("queued cancellation preserves order, replacement identity, and empty-queue test("reset clears queued work while a handler is suspended and admits the new instance", async () => { const origin = new Producer(); const instance = Instance.random(); - const dynamic = origin.requestsRoute(Path.from("room"), { instance }); + const dynamic = origin.routePrefix(Path.from("room"), { instance }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); const waits = [path, Path.from("room/obsolete")].map((name) => wire.demand(name, instance).catch(() => {})); @@ -650,7 +650,7 @@ test("reset clears queued work while a handler is suspended and admits the new i const old = (await requests.next()).value; expect(old?.path).toBe(path); const next = Instance.random(); - dynamic.updateRoute({ instance: next }); + wireOf(dynamic).update({ instance: next }); waits.push(wire.demand(path, next).catch(() => {})); const fresh = (await requests.next()).value; expect(fresh?.path).toBe(path); @@ -666,7 +666,7 @@ test("reset clears queued work while a handler is suspended and admits the new i test("concurrent handlers consume queued requests once across empty batches", async () => { const origin = new Producer(); - const dynamic = origin.requestsRoute(Path.from("room")); + const dynamic = origin.routePrefix(Path.from("room")); const handlers = [dynamic.requested(), dynamic.requested()]; const wire = wireOf(origin.consume()); const waits: Promise[] = []; diff --git a/js/net/src/origin-request.test.ts b/js/net/src/origin-request.test.ts index 38074e798ee..aabf3d94108 100644 --- a/js/net/src/origin-request.test.ts +++ b/js/net/src/origin-request.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; import { Producer as BroadcastProducer } from "./broadcast"; import { StreamCode, StreamError } from "./error"; -import { Route } from "./hop"; +import { RouteInfo } from "./hop"; import * as Instance from "./instance.ts"; -import { type Following, Producer, type Publication, type Requesting } from "./origin"; +import { type Following, Producer, type Requesting } from "./origin"; import * as Path from "./path"; import { wireOf } from "./wire"; @@ -12,25 +12,22 @@ const settle = () => new Promise((resolve) => setImmediate(resolve)); test("a follower moves through restart, end and a later start", async () => { const origin = new Producer(); const path = Path.from("room/audio"); - const firstPublication = origin.createBroadcast(path); - const first = firstPublication.broadcast; - firstPublication.announce(); + const firstRoute = origin.route(path); + const first = firstRoute.broadcast; const request: Following = origin.follow(path); - expect(request.active.peek()?.instance).toBe(firstPublication.route?.instance); - const secondPublication = origin.createBroadcast(path); - const second = secondPublication.broadcast; - secondPublication.announce(); + expect(request.active.peek()?.instance).toBe(firstRoute.instance); + const secondRoute = origin.route(path); + const second = secondRoute.broadcast; await settle(); - expect(request.active.peek()?.instance).toBe(secondPublication.route?.instance); + expect(request.active.peek()?.instance).toBe(secondRoute.instance); second.close(); await settle(); expect(request.active.peek()).toBeUndefined(); expect(request.closed.peek()).toBeUndefined(); - const thirdPublication = origin.createBroadcast(path); - const third = thirdPublication.broadcast; - thirdPublication.announce(); + const thirdRoute = origin.route(path); + const third = thirdRoute.broadcast; await settle(); - expect(request.active.peek()?.instance).toBe(thirdPublication.route?.instance); + expect(request.active.peek()?.instance).toBe(thirdRoute.instance); request.close(); third.close(); first.close(); @@ -41,15 +38,13 @@ test("an instance-pinned follower refuses another instance", async () => { const origin = new Producer(); const path = Path.from("room/audio"); const instance = Instance.random(); - const firstPublication = origin.createBroadcast(path); - const first = firstPublication.broadcast; - firstPublication.announce({ instance }); + const firstRoute = origin.route(path, { instance }); + const first = firstRoute.broadcast; const request = origin.follow(path, { instance }); - const secondPublication = origin.createBroadcast(path); - const second = secondPublication.broadcast; - secondPublication.announce({ instance: Instance.random() }); + const secondRoute = origin.route(path, { instance: Instance.random() }); + const second = secondRoute.broadcast; await settle(); - expect(request.active.peek()?.instance).not.toBe(secondPublication.route?.instance); + expect(request.active.peek()?.instance).not.toBe(secondRoute.instance); request.close(); first.close(); second.close(); @@ -59,14 +54,14 @@ test("an instance-pinned follower refuses another instance", async () => { test("a follower changes its handle on an in-place instance restart", async () => { const origin = new Producer(); const path = Path.from("room/audio"); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; const before = Instance.random(); - broadcastPublication.announce({ instance: before }); + const broadcastRoute = origin.route(path, { instance: before }); + const broadcast = broadcastRoute.broadcast; const request = origin.follow(path); const old = request.active.peek(); const after = Instance.random(); - broadcastPublication.announce({ instance: after }); + // A session's in-place restart: the route's instance changes under it. + wireOf(broadcastRoute).update({ instance: after }); await settle(); expect(request.active.peek()?.instance).toBe(after); expect(request.active.peek()).not.toBe(old); @@ -80,9 +75,8 @@ test("a closed follower never acquires another publisher", async () => { const path = Path.from("room/audio"); const request = origin.follow(path); request.close(); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; await settle(); expect(request.active.peek()).toBeUndefined(); expect(request.closed.peek()).toBeNull(); @@ -108,11 +102,10 @@ test("unrelated routes never notify followers", async () => { await settle(); let changes = 0; const stops = requests.map((request) => request.active.subscribe(() => changes++)); - const otherPublication = origin.createBroadcast(Path.from("unrelated")); - const other = otherPublication.broadcast; - otherPublication.announce(); + const otherRoute = origin.route(Path.from("unrelated")); + const other = otherRoute.broadcast; await settle(); - otherPublication.announce({ cost: 5n }); + otherRoute.setCost(5n); await settle(); other.close(); await settle(); @@ -167,29 +160,27 @@ test("followers track the serving prefix, ignoring descendants and re-prices", a const origin = new Producer(); const source = new BroadcastProducer(); const keeper = source.consume(); - const prefix = origin.requestsRoute(Path.from("pool")); + const prefix = origin.routePrefix(Path.from("pool")); const serving = (async () => { for await (const request of prefix.requested()) request.serve(source.consume()); })(); const request = origin.follow(Path.from("pool/job")); await settle(); - expect(request.active.peek()?.instance).toBe(prefix.route.instance); - const belowPublication = origin.createBroadcast(Path.from("pool/job/thumb")); - const below = belowPublication.broadcast; - belowPublication.announce(); + expect(request.active.peek()?.instance).toBe(prefix.instance); + const belowRoute = origin.route(Path.from("pool/job/thumb")); + const below = belowRoute.broadcast; await settle(); - expect(request.active.peek()?.instance).toBe(prefix.route.instance); - const exactPublication = origin.createBroadcast(Path.from("pool/job")); - const exact = exactPublication.broadcast; - exactPublication.announce(); + expect(request.active.peek()?.instance).toBe(prefix.instance); + const exactRoute = origin.route(Path.from("pool/job")); + const exact = exactRoute.broadcast; await settle(); - expect(request.active.peek()?.instance).toBe(exactPublication.route?.instance); - prefix.updateRoute({ cost: 5n }); + expect(request.active.peek()?.instance).toBe(exactRoute.instance); + prefix.setCost(5n); await settle(); - expect(request.active.peek()?.instance).toBe(exactPublication.route?.instance); + expect(request.active.peek()?.instance).toBe(exactRoute.instance); exact.close(); await settle(); - expect(request.active.peek()?.instance).toBe(prefix.route.instance); + expect(request.active.peek()?.instance).toBe(prefix.instance); prefix.close(); await settle(); expect(request.active.peek()).toBeUndefined(); @@ -205,12 +196,11 @@ test("followers track the serving prefix, ignoring descendants and re-prices", a test("a follower ignores a covering route whose scope excludes its path", async () => { const origin = new Producer(); const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); - const prefix = scoped.requestsRoute(Path.from("room/alice")); - const exactPublication = origin.createBroadcast(Path.from("room/alice")); - const exact = exactPublication.broadcast; - exactPublication.announce({ cost: 5n }); + const prefix = scoped.routePrefix(Path.from("room/alice")); + const exactRoute = origin.route(Path.from("room/alice"), { cost: 5n }); + const exact = exactRoute.broadcast; const request = origin.follow(Path.from("room/alice")); - expect(request.active.peek()?.instance).toBe(exactPublication.route?.instance); + expect(request.active.peek()?.instance).toBe(exactRoute.instance); exact.close(); await settle(); expect(request.active.peek()).toBeUndefined(); @@ -223,7 +213,7 @@ test("a follower tracks a route replaced by an identical one", async () => { const origin = new Producer(); const path = Path.from("room"); const serve = (source: BroadcastProducer) => { - const route = origin.requestsRoute(path); + const route = origin.routePrefix(path); void (async () => { for await (const request of route.requested()) request.serve(source.consume()); })(); @@ -233,7 +223,7 @@ test("a follower tracks a route replaced by an identical one", async () => { const first = serve(firstSource); const request = origin.follow(path); await settle(); - expect(request.active.peek()?.instance).toBe(first.route.instance); + expect(request.active.peek()?.instance).toBe(first.instance); // Same (default) route metadata: only the instance each claim minted differs. const secondSource = new BroadcastProducer(); @@ -241,7 +231,7 @@ test("a follower tracks a route replaced by an identical one", async () => { await settle(); first.close(); await settle(); - expect(request.active.peek()?.instance).toBe(second.route.instance); + expect(request.active.peek()?.instance).toBe(second.instance); request.close(); second.close(); @@ -254,7 +244,7 @@ test("an origin closing settles a follower a served route resolved", async () => const origin = new Producer(); const path = Path.from("room"); const source = new BroadcastProducer(); - const route = origin.requestsRoute(path); + const route = origin.routePrefix(path); void (async () => { for await (const request of route.requested()) request.serve(source.consume()); })(); @@ -273,14 +263,14 @@ test("an origin closing settles a follower a served route resolved", async () => test("a follower keeps a refusal that lands with a re-price", async () => { const origin = new Producer(); const path = Path.from("room"); - const route = origin.requestsRoute(path); + const route = origin.routePrefix(path); const requested = route.requested(); const request = origin.follow(path); const pending = (await requested.next()).value; if (!pending) throw new Error("expected a request"); // Both land in one turn: the watch notification must not retry the refused request. - route.updateRoute({ cost: 5n }); + route.setCost(5n); const denied = new Error("denied"); pending.reject(denied); await settle(); @@ -304,7 +294,7 @@ for (const received of [false, true]) { const events: string[] = []; const stop = wireOf(origin).interests((prefix, wanted) => events.push(`${prefix} ${wanted}`)); const source = new BroadcastProducer(); - const route = received ? wireOf(origin).receive(path, Route.default) : origin.requestsRoute(path); + const route = received ? wireOf(origin).receive(path, RouteInfo.default) : origin.routePrefix(path); void (async () => { for await (const request of route.requested()) request.serve(source.consume()); })(); @@ -325,55 +315,85 @@ for (const received of [false, true]) { }); } -test("a claim answer is hidden until published under its own instance", async () => { +test("a hidden job route is hidden until announced under its own instance", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - const prepared = pending.prepare(); - prepared.broadcast.createTrack("video"); + const job = pending.route({ hidden: true }); + job.broadcast.createTrack("video"); await settle(); expect(request.active.peek()).toBeUndefined(); - const published = prepared.publish(); + job.announce(); await settle(); - expect(published.route?.instance).toBeDefined(); - expect(request.active.peek()?.instance).toBe(published.route?.instance); + expect(request.active.peek()?.instance).toBe(job.instance); request.close(); - published.broadcast.close(); + job.close(); claim.close(); origin.close(); }); -test("a prepared answer cannot announce itself before publish", async () => { +test("a visible job route answers its waiting requesters at once", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); + const requests = claim.requested(); + const first = origin.request(path); + const pending = (await requests.next()).value; + if (!pending) throw new Error("expected a request"); + const shared = origin.request(path); + const job = pending.route(); + expect(job.announced).toBe(true); + expect(job.instance).not.toBe(claim.instance); + await settle(); + for (const request of [first, shared]) expect(request.active.peek()?.instance).toBe(job.instance); + // The request is answered: routing it again is refused. + expect(() => pending.route()).toThrow("request is closed"); + + // Ending the job withdraws only its path: the claim keeps answering later requests. + job.close(); + await settle(); + const later = origin.request(path); + const next = (await requests.next()).value; + if (!next) throw new Error("expected another request"); + const again = next.route({ instance: Instance.parse("stable"), epoch: 2n }); + await settle(); + expect(again.instance).toBe(Instance.parse("stable")); + expect(later.active.peek()?.instance).toBe(again.instance); + for (const request of [first, shared, later]) request.close(); + again.close(); + claim.close(); + origin.close(); +}); + +test("a hidden job route's broadcast cannot announce itself", async () => { + const origin = new Producer(); + const path = Path.from("room/alice"); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - const prepared = pending.prepare(); - expect(prepared.broadcast).not.toHaveProperty("announce"); - expect(prepared.broadcast).not.toHaveProperty("route"); + const job = pending.route({ hidden: true }); + expect(job.broadcast).not.toHaveProperty("announce"); + expect(job.broadcast).not.toHaveProperty("route"); await settle(); expect(request.active.peek()).toBeUndefined(); - const published = prepared.publish(); - const instance = published.route?.instance; - expect(instance).toBeDefined(); + job.announce(); await settle(); - expect(request.active.peek()?.instance).toBe(instance); + expect(request.active.peek()?.instance).toBe(job.instance); request.close(); - published.broadcast.close(); + job.close(); claim.close(); origin.close(); }); test("an application serves held content without an exact route", async () => { const origin = new Producer(); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const request = origin.request(Path.from("room/alice")); const pending = (await requests.next()).value; @@ -392,20 +412,20 @@ test("an application serves held content without an exact route", async () => { origin.close(); }); -for (const withdraw of ["unannounce", "replace"] as const) { - test(`a published answer is not cached after ${withdraw}`, async () => { +for (const withdraw of ["withdraw", "replace"] as const) { + test(`a job's route is not cached after ${withdraw}`, async () => { const origin = new Producer(); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const path = Path.from("room/alice"); const first = origin.request(path); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - const publication = pending.prepare().publish(); + const job = pending.route(); await settle(); const held = first.active.peek()?.clone(); - if (withdraw === "unannounce") publication.unannounce(); - else origin.createBroadcast(path).broadcast.close(); + if (withdraw === "withdraw") job.withdraw(); + else origin.route(path, { hidden: true }).broadcast.close(); const next = origin.request(path); await settle(); expect(next.active.peek()).toBeUndefined(); @@ -415,41 +435,43 @@ for (const withdraw of ["unannounce", "replace"] as const) { held?.close(); next.close(); first.close(); - publication.broadcast.close(); + job.close(); claim.close(); origin.close(); }); } -test("closing a prepared answer refuses the request and closes its broadcast", async () => { +test("closing a hidden job route refuses the request and closes its broadcast", async () => { const origin = new Producer(); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const request = origin.request(Path.from("room/alice")); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - const prepared = pending.prepare(); - prepared.close(); - prepared.close(); - prepared[Symbol.dispose](); + const job = pending.route({ hidden: true }); + job.close(); + job.close(); + job[Symbol.dispose](); await settle(); expect(request.closed.peek()).toBeInstanceOf(Error); - expect(prepared.broadcast.closed.peek()).toBeDefined(); + expect(job.broadcast.closed.peek()).toBeDefined(); claim.close(); origin.close(); }); -test("an answer prepared for an abandoned claim is never announced", async () => { +test("a job route for an abandoned claim is never announced", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - const prepared = pending.prepare(); + const job = pending.route({ hidden: true }); claim.close(); - expect(() => prepared.publish()).toThrow("request is closed"); + expect(() => job.announce()).toThrow("unroutable"); + expect(job.announced).toBe(false); + expect(job.broadcast.closed.peek()).toBeDefined(); const announced = origin.follow(path); await settle(); expect(announced.active.peek()).toBeUndefined(); @@ -459,31 +481,31 @@ test("an answer prepared for an abandoned claim is never announced", async () => }); for (const cleanup of ["close", "dispose", "broadcast"] as const) { - test(`prepared ${cleanup} releases shared requesters and admits a new request`, async () => { + test(`a hidden job route's ${cleanup} releases shared requesters and admits a new request`, async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const first = origin.request(path); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - const prepared = pending.prepare(); + const job = pending.route({ hidden: true }); const shared = origin.request(path); - if (cleanup === "close") prepared.close(); - else if (cleanup === "dispose") prepared[Symbol.dispose](); - else prepared.broadcast.close(); + if (cleanup === "close") job.close(); + else if (cleanup === "dispose") job[Symbol.dispose](); + else job.broadcast.close(); // Requested in the same turn, so the release cannot wait on a reactive notification. const later = origin.request(path); await settle(); - prepared.close(); - prepared[Symbol.dispose](); + job.close(); + job[Symbol.dispose](); for (const request of [first, shared]) { expect(request.closed.peek()).toBeInstanceOf(StreamError); expect((request.closed.peek() as StreamError).code).toBe(StreamCode.Unroutable); } expect(later.closed.peek()).toBeUndefined(); - expect(() => prepared.publish()).toThrow("prepared answer is closed"); + expect(() => job.announce()).toThrow("broadcast is closed"); const next = (await requests.next()).value; if (!next) throw new Error("expected another request"); expect(next).not.toBe(pending); @@ -495,14 +517,14 @@ for (const cleanup of ["close", "dispose", "broadcast"] as const) { }); } -test("a throwing handler disposes its prepared answer and leaves the claim answerable", async () => { +test("a throwing handler disposes its job route and leaves the claim answerable", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const failed = (async () => { for await (const request of claim.requested()) { - using prepared = request.prepare(); - prepared.broadcast.createTrack("video"); + using job = request.route({ hidden: true }); + job.broadcast.createTrack("video"); throw new Error("handler failed"); } })(); @@ -515,8 +537,8 @@ test("a throwing handler disposes its prepared answer and leaves the claim answe const pending = (await requests.next()).value; if (!pending) throw new Error("expected another request"); { - using prepared = pending.prepare(); - prepared.close(); + using job = pending.route({ hidden: true }); + job.close(); } await settle(); expect((later.closed.peek() as StreamError).code).toBe(StreamCode.Unroutable); @@ -524,44 +546,40 @@ test("a throwing handler disposes its prepared answer and leaves the claim answe origin.close(); }); -test("using transfers a published answer without closing it on disposal", async () => { +test("announcing a job route again keeps it answering under one instance", async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const request = origin.request(path); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - let published: Publication; - { - using prepared = pending.prepare(); - prepared.broadcast.createTrack("data").writeFrame({ payload: new TextEncoder().encode("published") }); - published = prepared.publish(); - expect(() => prepared.publish()).toThrow("prepared answer is closed"); - prepared.close(); - } + const job = pending.route({ hidden: true }); + job.broadcast.createTrack("data").writeFrame({ payload: new TextEncoder().encode("published") }); + job.announce(); + job.announce(); await settle(); - expect(published.broadcast.closed.peek()).toBeUndefined(); - expect(request.active.peek()?.instance).toBe(published.route?.instance); + expect(job.broadcast.closed.peek()).toBeUndefined(); + expect(request.active.peek()?.instance).toBe(job.instance); const reader = request.active.peek()?.track("data").subscribe().ordered(); expect(await reader?.readString()).toBe("published"); reader?.close(); request.close(); - published.broadcast.close(); + job.close(); claim.close(); origin.close(); }); -for (const cleanup of ["close", "dispose", "broadcast", "publish"] as const) { - test(`an old prepared ${cleanup} cannot refuse the next request for its path`, async () => { +for (const cleanup of ["close", "dispose", "broadcast", "announce"] as const) { + test(`an old job route's ${cleanup} cannot refuse the next request for its path`, async () => { const origin = new Producer(); const path = Path.from("room/alice"); - const claim = origin.requests(Path.from("room")); + const claim = origin.routePrefix(Path.from("room")); const requests = claim.requested(); const first = origin.request(path); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - const old = pending.prepare(); + const old = pending.route({ hidden: true }); first.close(); await settle(); const later = origin.request(path); @@ -570,34 +588,33 @@ for (const cleanup of ["close", "dispose", "broadcast", "publish"] as const) { if (cleanup === "close") old.close(); else if (cleanup === "dispose") old[Symbol.dispose](); else if (cleanup === "broadcast") old.broadcast.close(); - else expect(() => old.publish()).toThrow("request is closed"); + else expect(() => old.announce()).toThrow("unroutable"); await settle(); expect(later.closed.peek()).toBeUndefined(); - using prepared = next.prepare(); - prepared.broadcast.createTrack("data").writeFrame({ payload: new TextEncoder().encode(cleanup) }); - const published = prepared.publish(); + using job = next.route({ hidden: true }); + job.broadcast.createTrack("data").writeFrame({ payload: new TextEncoder().encode(cleanup) }); + job.announce(); await settle(); - expect(later.active.peek()?.instance).toBe(published.route?.instance); + expect(later.active.peek()?.instance).toBe(job.instance); const reader = later.active.peek()?.track("data").subscribe().ordered(); expect(await reader?.readString()).toBe(cleanup); reader?.close(); old.close(); later.close(); - published.broadcast.close(); claim.close(); origin.close(); }); } -test("a forwarded request cannot be prepared by setting a publisher property", async () => { +test("a forwarded request cannot be routed by setting a publisher property", async () => { const origin = new Producer(); - const route = origin.requestsRoute(Path.from("room")); + const route = wireOf(origin).receive(Path.from("room")); const requests = route.requested(); const request = origin.request(Path.from("room/alice")); const pending = (await requests.next()).value; if (!pending) throw new Error("expected a request"); Object.assign(pending, { publisher: origin }); - expect(() => pending.prepare()).toThrow("forwarded requests cannot be prepared"); + expect(() => pending.route()).toThrow("unroutable"); pending.reject(new Error("refused")); request.close(); route.close(); @@ -611,7 +628,7 @@ test("an instance-pinned follower resumes when its instance returns after a gap" const source = new BroadcastProducer(); const keeper = source.consume(); const serve = (instance: Instance.Valid, broadcast: BroadcastProducer) => { - const route = origin.requestsRoute(path, { instance: instance }); + const route = origin.routePrefix(path, { instance: instance }); void (async () => { for await (const request of route.requested()) request.serve(broadcast.consume()); })(); @@ -681,10 +698,9 @@ test("a follower keeps its scoped discovery across instances until closed", asyn synced?.(); await settle(); expect(follower.closed.peek()).toBeUndefined(); - const publication = origin.createBroadcast(path); - publication.announce(); + const publication = origin.route(path); await settle(); - expect(follower.active.peek()?.instance).toBe(publication.route?.instance); + expect(follower.active.peek()?.instance).toBe(publication.instance); publication.broadcast.close(); await settle(); // The instance ending keeps the follower and its discovery open for the next one. @@ -763,7 +779,7 @@ test("a pending request waits for a session before concluding absence", async () test("a follower retains a selected handler's explicit unroutable refusal", async () => { const origin = new Producer(); const path = Path.from("room/audio"); - const route = origin.requestsRoute(path); + const route = origin.routePrefix(path); const pending = route.requested(); const requested = origin.follow(path); const request = (await pending.next()).value; @@ -780,25 +796,24 @@ test("a follower retains a selected handler's explicit unroutable refusal", asyn test("a pending request retries a route gap and never retargets after resolving", async () => { const origin = new Producer(); const path = Path.from("room/audio"); - const first = origin.requestsRoute(path); + const first = origin.routePrefix(path); const requested = origin.request(path); const before = await first.requested().next(); expect(before.value).toBeDefined(); first.close(); await settle(); expect(requested.closed.peek()).toBeUndefined(); - const second = origin.requestsRoute(path); + const second = origin.routePrefix(path); const pending = (await second.requested().next()).value; if (!pending) throw new Error("replacement route was not requested"); const source = new BroadcastProducer(); const keeper = source.consume(); pending.serve(source.consume()); await settle(); - expect(requested.active.peek()?.instance).toBe(second.route.instance); - const replacement = origin.createBroadcast(path); - replacement.announce(); + expect(requested.active.peek()?.instance).toBe(second.instance); + const replacement = origin.route(path); await settle(); - expect(requested.active.peek()?.instance).toBe(second.route.instance); + expect(requested.active.peek()?.instance).toBe(second.instance); second.close(); await settle(); expect(requested.closed.peek()).toBeInstanceOf(StreamError); @@ -834,8 +849,8 @@ test.each(["request", "follow"] as const)("a %s pinned to a losing instance reso const origin = new Producer(); const path = Path.from("room/audio"); const pinned = Instance.random(); - const losing = origin.requestsRoute(path, { instance: pinned, cost: 9n }); - const winning = origin.requestsRoute(path, { instance: Instance.random(), cost: 1n }); + const losing = origin.routePrefix(path, { instance: pinned, cost: 9n }); + const winning = origin.routePrefix(path, { instance: Instance.random(), cost: 1n }); const asked = losing.requested(); const requested = origin[method](path, { instance: pinned }); const pending = (await asked.next()).value; @@ -855,12 +870,12 @@ test.each(["request", "follow"] as const)("a %s pinned to a losing instance reso const other = new BroadcastProducer(); answer.serve(other.consume()); await settle(); - expect(plain.active.peek()?.instance).toBe(winning.route.instance); + expect(plain.active.peek()?.instance).toBe(winning.instance); requested.close(); again.close(); await settle(); - expect(plain.active.peek()?.instance).toBe(winning.route.instance); + expect(plain.active.peek()?.instance).toBe(winning.instance); plain.close(); source.close(); other.close(); @@ -873,10 +888,9 @@ test("a peer's pin is served by a local broadcast that loses the path", async () const origin = new Producer(); const wire = wireOf(origin.consume()); const path = Path.from("room/audio"); - const publication = origin.createBroadcast(path); const pinned = Instance.random(); - publication.announce({ instance: pinned, cost: 9n }); - const winning = origin.requestsRoute(path, { instance: Instance.random(), cost: 1n }); + const publication = origin.route(path, { instance: pinned, cost: 9n }); + const winning = origin.routePrefix(path, { instance: Instance.random(), cost: 1n }); expect(wire.local(path)).toBeUndefined(); expect(wire.local(path, pinned)).toBeDefined(); expect(await wire.demand(path, pinned)).toBeDefined(); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 36ad412a0c5..8c6ad7afda3 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -4,7 +4,7 @@ import { getter, Once, race, Signal } from "@moq/signals"; import { announcedView } from "./announced.test-util"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import { StreamCode, StreamError } from "./error.ts"; -import { HopSchema, Route } from "./hop.ts"; +import { type Cost, HopSchema, RouteInfo } from "./hop.ts"; import * as Instance from "./instance.ts"; import { chainHash, presented, spreadHash } from "./internal.ts"; import { SessionBroadcast, textFrame } from "./mock.ts"; @@ -16,9 +16,8 @@ import { Timescale } from "./time.ts"; import { overrideBroadcastWire, wireOf } from "./wire.ts"; function publish(origin: Producer, path: Path.Valid) { - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; return broadcast; } @@ -28,7 +27,12 @@ const PEER = HopSchema.parse(10n); const INSTANCE = Instance.parse("01900000-0000-7000-8000-000000000001"); /** Land a received prefix, served from `consume`, the way a session does. */ -function serve(origin: Producer, prefix: Path.Valid, consume: () => BroadcastConsumer, route: Route = Route.default) { +function serve( + origin: Producer, + prefix: Path.Valid, + consume: () => BroadcastConsumer, + route: RouteInfo = RouteInfo.default, +) { const handle = wireOf(origin).receive(prefix, route); void (async () => { try { @@ -120,35 +124,35 @@ test("a borrowed table exposes dynamic serving and a live scoped broadcast map", const table: Table = origin; const scope = Path.Pattern.parse("room/**"); const live = await announcedView(origin, scope); - const changes: ReadonlyMap[] = []; + const changes: ReadonlyMap[] = []; const stop = live.subscribe((value) => changes.push(value)); await new Promise((resolve) => setImmediate(resolve)); expect(live.peek().size).toBe(0); const other = publish(origin, Path.from("other")); const localPath = Path.from("room/alice"); - const localPublication = table.createBroadcast(localPath); - const local = localPublication.broadcast; + const localRoute = table.route(localPath, { hidden: true, instance: INSTANCE, cost: 4n }); + const local = localRoute.broadcast; await new Promise((resolve) => setImmediate(resolve)); expect(live.peek().has(localPath)).toBe(false); await new Promise((resolve) => setImmediate(resolve)); expect(live.peek().has(Path.from("other"))).toBe(false); - localPublication.announce({ instance: INSTANCE, cost: 4n }); + localRoute.announce(); await settle(); await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(localPath)).toEqual(Route.normalize({ instance: INSTANCE, cost: 4n })); + expect(live.peek().get(localPath)).toEqual(RouteInfo.normalize({ instance: INSTANCE, cost: 4n })); await new Promise((resolve) => setImmediate(resolve)); expect(changes.some((value) => value.get(localPath)?.cost === 4n)).toBe(true); const prefix = Path.from("room"); - const dynamic = table.requestsRoute(prefix, { cost: 2n }); + const dynamic = table.routePrefix(prefix, { cost: 2n }); await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(prefix)).toEqual(Route.normalize({ ...dynamic.route, cost: 2n })); - dynamic.updateRoute({ cost: 3n }); + expect(live.peek().get(prefix)).toEqual(RouteInfo.normalize({ ...wireOf(dynamic).info, cost: 2n })); + dynamic.setCost(3n); await settle(); await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(prefix)).toEqual(Route.normalize({ ...dynamic.route, cost: 3n })); + expect(live.peek().get(prefix)).toEqual(RouteInfo.normalize({ ...wireOf(dynamic).info, cost: 3n })); dynamic.close(); local.close(); @@ -167,26 +171,26 @@ test("an announced local path competes with a received route on cost", async () const remote = wireOf(origin).receive(path, { instance: INSTANCE, hops: [PEER], cost: 5n }); const live = await announcedView(consumer); await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(path)).toEqual(Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); + expect(live.peek().get(path)).toEqual(RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); // Unannounced, it competes for nothing. - const localPublication = origin.createBroadcast(path); - const local = localPublication.broadcast; + const localRoute = origin.route(path, { hidden: true, instance: INSTANCE, cost: 9n }); + const local = localRoute.broadcast; await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(path)).toEqual(Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); + expect(live.peek().get(path)).toEqual(RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); // Cheaper wins; a tie falls to the local broadcast. - localPublication.announce({ instance: INSTANCE, cost: 9n }); + localRoute.announce(); await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(path)).toEqual(Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); - localPublication.announce({ instance: INSTANCE, cost: 5n }); + expect(live.peek().get(path)).toEqual(RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); + localRoute.setCost(5n); await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(path)).toEqual(Route.normalize({ instance: INSTANCE, cost: 5n })); + expect(live.peek().get(path)).toEqual(RouteInfo.normalize({ instance: INSTANCE, cost: 5n })); local.close(); await settle(); await new Promise((resolve) => setImmediate(resolve)); - expect(live.peek().get(path)).toEqual(Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); + expect(live.peek().get(path)).toEqual(RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 5n })); remote.close(); await settle(); @@ -207,7 +211,7 @@ test("a scoped route remains visible when an exact local path is outside the sco await new Promise((resolve) => setImmediate(resolve)); - expect((await announcedView(origin)).peek().get(path)).toMatchObject(Route.default); + expect((await announcedView(origin)).peek().get(path)).toMatchObject(RouteInfo.default); await new Promise((resolve) => setImmediate(resolve)); expect(live.peek().get(path)).toMatchObject({ cost: 5n }); @@ -309,7 +313,7 @@ test("a superseded broadcast closes, and its subscribers keep reading", async () const consumer = origin.consume(); const path = Path.from("room"); - const publication = origin.createBroadcast(path); + const publication = origin.route(path, { hidden: true }); const first = publication.broadcast; const track = first.createTrack("video", { timescale: Timescale.MILLI }); publication.announce(); @@ -317,13 +321,12 @@ test("a superseded broadcast closes, and its subscribers keep reading", async () const subscriber = mine?.track("video").subscribe(); if (!subscriber) throw new Error("expected a subscriber"); - const replacement = origin.createBroadcast(path); - replacement.announce(); + const replacement = origin.route(path); // The origin closes the old producer itself, whoever still holds a handle on it. expect(first.closed.peek()).toBeNull(); // Its stale publication never displaces or retracts the replacement. - expect(() => publication.announce({ instance: INSTANCE })).toThrow("broadcast is closed"); - publication.unannounce(); + expect(() => publication.announce()).toThrow("broadcast is closed"); + publication.withdraw(); const current = await routed(consumer, path); expect(readsFrom(current, replacement.broadcast)).toBe(true); current?.close(); @@ -438,7 +441,7 @@ test("a remote entry resolves by path and retracts on dispose", async () => { // Announced streams include remote entries. const announced = consumer.routes(); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: Route.default }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: RouteInfo.default }); dispose(); expect(await announced.next()).toMatchObject({ prefix: path, kind: "end" }); @@ -490,7 +493,7 @@ test("a local publish shadows a remote entry", async () => { // One path, one announcement, even though both tables route it. const announced = consumer.routes(); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: Route.default }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: RouteInfo.default }); // Dropping the local publish falls back to the remote entry without a retraction. local.close(); @@ -719,7 +722,7 @@ test("an identical reconnect restarts without an instance and is quiet with one" const consumer = origin.consume(); const path = Path.from("room"); const announced = consumer.routes(); - const route = Route.normalize({ instance, hops: [PEER] }); + const route = RouteInfo.normalize({ instance, hops: [PEER] }); const old = wireOf(origin).receive(path, route); expect(await announced.next()).toMatchObject({ prefix: path, kind: "start" }); @@ -730,7 +733,7 @@ test("an identical reconnect restarts without an instance and is quiet with one" // Retracting the stale twin is quiet, and a re-price of the winner is an update. old.close(); - fresh.updateRoute({ ...route, cost: 5n }); + wireOf(fresh).update({ ...route, cost: 5n }); expect(await announced.next()).toMatchObject({ prefix: path, kind: "update", route: { cost: 5n } }); fresh.close(); @@ -1024,47 +1027,56 @@ test("a seeded route still notifies when it retracts", async () => { origin.close(); }); -test("createBroadcast is invisible to everyone until announce", async () => { +test("a hidden route is invisible to everyone until announce", async () => { const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = origin.route(path, { hidden: true, cost: 4n }); + const broadcast = broadcastRoute.broadcast; expect(wireOf(consumer).routes(path)).toBe(false); expect(wireOf(consumer).advertised.peek()?.has(path)).toBe(false); const request = consumer.request(path); expect(request.active.peek()).toBeUndefined(); const announced = consumer.routes(); - broadcastPublication.announce({ cost: 4n }); + expect(broadcastRoute.announced).toBe(false); + broadcastRoute.announce(); + expect(broadcastRoute.announced).toBe(true); expect(wireOf(consumer).routes(path)).toBe(true); expect(wireOf(consumer).advertised.peek()?.has(path)).toBe(true); expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", - route: { hops: [], cost: 4n }, + route: { instance: broadcastRoute.instance, hops: [], cost: 4n }, }); const first = request.active.peek(); - expect(first).toBeDefined(); + expect(first?.instance).toBe(broadcastRoute.instance); // Off the air for local consumers and peers alike, which ends the request on it. - broadcastPublication.unannounce(); + broadcastRoute.withdraw(); expect(wireOf(consumer).routes(path)).toBe(false); expect(wireOf(consumer).advertised.peek()?.has(path)).toBe(false); expect(await announced.next()).toMatchObject({ prefix: path, kind: "end" }); expect(request.active.peek()).toBeUndefined(); expect(request.closed.peek()).toBeInstanceOf(Error); - // Back on the air for a fresh request, through a fresh handle. - broadcastPublication.announce(); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "start" }); + // Back on the air for a fresh request, through a fresh handle, continuing the same instance. + const instance = broadcastRoute.instance; + broadcastRoute.announce(); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: { instance } }); const fresh = consumer.request(path); const again = fresh.active.peek(); expect(again).toBeDefined(); expect(again).not.toBe(first); + expect(again?.instance).toBe(instance); + // Closing the broadcast retracts the route at once, for good. broadcast.close(); + expect(broadcastRoute.announced).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); + expect(() => broadcastRoute.announce()).toThrow("broadcast is closed"); + expect(() => broadcastRoute.setCost(1n)).toThrow("broadcast is closed"); expect(await announced.next()).toMatchObject({ prefix: path, kind: "end" }); fresh.close(); request.close(); @@ -1072,6 +1084,90 @@ test("createBroadcast is invisible to everyone until announce", async () => { origin.close(); }); +test("closing the origin ends a hidden or withdrawn prefix route's requests", async () => { + const origin = new Producer(); + const hidden = origin.routePrefix(Path.from("hidden"), { hidden: true }); + const withdrawn = origin.routePrefix(Path.from("withdrawn")); + withdrawn.withdraw(); + const pending = [hidden.requested().next(), withdrawn.requested().next()]; + + origin.close(); + for (const next of await Promise.all(pending)) expect(next.done).toBe(true); +}); + +test("a prefix route refuses to announce or re-price once its origin closed, like an exact one", () => { + const origin = new Producer(); + const prefix = origin.routePrefix(Path.from("live")); + const exact = origin.route(Path.from("room")); + origin.close(); + expect(prefix.announced).toBe(false); + expect(() => prefix.announce()).toThrow("origin is closed"); + expect(() => prefix.setCost(1n)).toThrow("origin is closed"); + expect(() => exact.announce()).toThrow(); + expect(() => exact.setCost(1n)).toThrow(); +}); + +test("a prefix route can start hidden, withdraw and come back while it keeps yielding requests", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const prefix = Path.from("jobs"); + const path = Path.from("jobs/a"); + const route = origin.routePrefix(prefix, { hidden: true, cost: 3n }); + expect(() => route.broadcast).toThrow("a prefix route owns no broadcast"); + expect(route.announced).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); + const requests = route.requested(); + + route.announce(); + expect(route.announced).toBe(true); + expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual( + RouteInfo.normalize({ instance: route.instance, cost: 3n }), + ); + const first = consumer.request(path); + const asked = (await requests.next()).value; + if (!asked) throw new Error("expected a request"); + + // Withdrawn, the route answers nothing: the handler's late answer is dropped, and the request + // waits for a route, as it would between reconnects. + route.withdraw(); + expect(route.announced).toBe(false); + expect(wireOf(consumer).routes(path)).toBe(false); + const dropped = new BroadcastProducer(); + asked.serve(dropped); + await settle(); + expect(first.active.peek()).toBeUndefined(); + + // Back under the same instance, the same iterator yields the request again. + const instance = route.instance; + route.announce(); + expect(route.instance).toBe(instance); + const second = consumer.request(path); + const next = (await requests.next()).value; + if (!next) throw new Error("expected another request"); + const source = new BroadcastProducer(); + next.serve(source); + await settle(); + for (const request of [first, second]) expect(request.active.peek()?.instance).toBe(instance); + + first.close(); + second.close(); + dropped.close(); + route.close(); + expect((await requests.next()).done).toBe(true); + expect(() => route.announce()).toThrow("route is closed"); + source.close(); + origin.close(); +}); + +test("an exact route yields no requests", async () => { + const origin = new Producer(); + const route = origin.route(Path.from("room")); + expect((await route.requested().next()).done).toBe(true); + route.close(); + expect(route.broadcast.closed.peek()).toBeNull(); + origin.close(); +}); + test("a request prefers a cheaper received route over an announced local broadcast", async () => { const origin = new Producer(); const consumer = origin.consume(); @@ -1081,11 +1177,10 @@ test("a request prefers a cheaper received route over an announced local broadca origin, path, provider(upstream), - Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 1n }), + RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 1n }), ); - const localPublication = origin.createBroadcast(path); - const local = localPublication.broadcast; - localPublication.announce({ instance: INSTANCE, cost: 3n }); + const localRoute = origin.route(path, { instance: INSTANCE, cost: 3n }); + const local = localRoute.broadcast; // The cheaper route is served on demand, so it resolves only once its handler answers. const request = consumer.request(path); @@ -1098,7 +1193,7 @@ test("a request prefers a cheaper received route over an announced local broadca // Re-priced below it, the local broadcast of the same instance wins at once: the request's // broadcast stays, and its tracks read the local one. - localPublication.announce({ instance: INSTANCE, cost: 0n }); + localRoute.setCost(0n); expect(await announced.next()).toMatchObject({ prefix: path, kind: "update", @@ -1125,16 +1220,25 @@ test("the cheapest received route competes with the local broadcast, not the new served.push(name); return upstream.consume(); }; - const cheap = serve(origin, path, via("cheap"), Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 1n })); - const pricey = serve(origin, path, via("pricey"), Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 10n })); - const localPublication = origin.createBroadcast(path); - const local = localPublication.broadcast; - localPublication.announce({ instance: INSTANCE, cost: 5n }); + const cheap = serve( + origin, + path, + via("cheap"), + RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 1n }), + ); + const pricey = serve( + origin, + path, + via("pricey"), + RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 10n }), + ); + const localRoute = origin.route(path, { instance: INSTANCE, cost: 5n }); + const local = localRoute.broadcast; await new Promise((resolve) => setImmediate(resolve)); expect((await announcedView(consumer)).peek().get(path)).toEqual( - Route.normalize({ instance: INSTANCE, hops: [PEER], cost: 1n }), + RouteInfo.normalize({ instance: INSTANCE, hops: [PEER], cost: 1n }), ); const request = consumer.request(path); await settle(); @@ -1154,7 +1258,7 @@ test("the cheapest received route competes with the local broadcast, not the new test("a handle serves a request under live", async () => { const origin = new Producer(); const consumer = origin.consume(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); expect(await consumer.routes().next()).toMatchObject({ prefix: Path.from("live"), kind: "start" }); @@ -1181,7 +1285,7 @@ test("a handle serves a request under live", async () => { test("an originated route serves held content a session delivered", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const it = handle.requested(); const pending = wireOf(origin.consume()).demand(Path.from("live/cam")); const { value: req } = await it.next(); @@ -1219,10 +1323,10 @@ test("a received route still serves a broadcast a session delivered", async () = test("a re-priced route is delivered as an update", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const announced = origin.consume().routes(); expect(await announced.next()).toMatchObject({ prefix: Path.from("live"), kind: "start" }); - handle.updateRoute({ cost: 5n }); + handle.setCost(5n); expect(await announced.next()).toMatchObject({ prefix: Path.from("live"), kind: "update" }); handle.close(); expect(await announced.next()).toMatchObject({ prefix: Path.from("live"), kind: "end" }); @@ -1232,7 +1336,7 @@ test("a re-priced route is delivered as an update", async () => { test("an accepted dynamic broadcast is retired when it closes, and its handle resumes", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const path = Path.from("live/cam"); const request = origin.request(path); const it = handle.requested(); @@ -1266,7 +1370,7 @@ test("an accepted dynamic broadcast is retired when it closes, and its handle re test("reject surfaces the error from demand", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); const pending = wireOf(consumer).demand(Path.from("live/cam")); @@ -1283,11 +1387,11 @@ test("a peer is offered and served the cheapest originated route", async () => { const origin = new Producer(); const consumer = origin.consume(); const prefix = Path.from("live"); - const cheap = origin.requestsRoute(prefix, { instance: INSTANCE, cost: 1n }); - const pricey = origin.requestsRoute(prefix, { instance: INSTANCE, cost: 10n }); + const cheap = origin.routePrefix(prefix, { instance: INSTANCE, cost: 1n }); + const pricey = origin.routePrefix(prefix, { instance: INSTANCE, cost: 10n }); expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual( - Route.normalize({ instance: INSTANCE, cost: 1n }), + RouteInfo.normalize({ instance: INSTANCE, cost: 1n }), ); const pending = wireOf(consumer).demand(Path.from("live/cam")); const { value: req } = await cheap.requested().next(); @@ -1296,15 +1400,14 @@ test("a peer is offered and served the cheapest originated route", async () => { expect(await pending).toBeDefined(); // An exact-path local broadcast competes with them on cost too. - const localPublication = origin.createBroadcast(prefix); - const local = localPublication.broadcast; - localPublication.announce({ instance: INSTANCE, cost: 5n }); + const localRoute = origin.route(prefix, { instance: INSTANCE, cost: 5n }); + const local = localRoute.broadcast; expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual( - Route.normalize({ instance: INSTANCE, cost: 1n }), + RouteInfo.normalize({ instance: INSTANCE, cost: 1n }), ); - localPublication.announce({ instance: INSTANCE, cost: 0n }); + localRoute.setCost(0n); expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual( - Route.normalize({ instance: INSTANCE, cost: 0n }), + RouteInfo.normalize({ instance: INSTANCE, cost: 0n }), ); local.close(); @@ -1316,7 +1419,7 @@ test("a peer is offered and served the cheapest originated route", async () => { test("a shared reject still surfaces from demand", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); @@ -1338,7 +1441,7 @@ test("a shared reject still surfaces from demand", async () => { test("a request refusal does not keep demand from asking again", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); @@ -1362,7 +1465,7 @@ test("a request refusal does not keep demand from asking again", async () => { test("advancing requested without settling rejects the previous request", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const consumer = origin.consume(); const it = handle.requested(); @@ -1386,7 +1489,7 @@ test("advancing requested without settling rejects the previous request", async test("a rejected request is not asked of the same route again", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const it = handle.requested(); const request = origin.request(Path.from("live/cam")); @@ -1421,7 +1524,7 @@ test("an outranked route keeps serving until its replacement answers", async () const older = new BroadcastProducer(); const keepOlder = older.consume(); - const disposeOlder = serve(origin, path, () => keepOlder.clone(), Route.normalize({ instance: INSTANCE })); + const disposeOlder = serve(origin, path, () => keepOlder.clone(), RouteInfo.normalize({ instance: INSTANCE })); const request = consumer.request(path); await settle(); @@ -1541,16 +1644,21 @@ test("a request on a replaced instance fails over to another route of it", async const primary = new BroadcastProducer(); const keepPrimary = primary.consume(); - const disposePrimary = serve(origin, path, () => keepPrimary.clone(), Route.normalize({ instance: INSTANCE })); + const disposePrimary = serve(origin, path, () => keepPrimary.clone(), RouteInfo.normalize({ instance: INSTANCE })); const replica = new BroadcastProducer(); - const disposeReplica = serve(origin, path, provider(replica), Route.normalize({ instance: INSTANCE, cost: 5n })); + const disposeReplica = serve( + origin, + path, + provider(replica), + RouteInfo.normalize({ instance: INSTANCE, cost: 5n }), + ); const request = consumer.request(path); await settle(); const first = request.active.peek(); expect(first).toBeDefined(); const restarted = new BroadcastProducer(); - const disposeRestarted = serve(origin, path, provider(restarted), Route.normalize({ instance: newer })); + const disposeRestarted = serve(origin, path, provider(restarted), RouteInfo.normalize({ instance: newer })); await settle(); expect(request.active.peek()).toBe(first); @@ -1589,17 +1697,16 @@ test("a request on a replaced instance fails over to a local replica of it", asy const primary = new BroadcastProducer(); const keepPrimary = primary.consume(); - const disposePrimary = serve(origin, path, () => keepPrimary.clone(), Route.normalize({ instance: INSTANCE })); - const localPublication = origin.createBroadcast(path); - const local = localPublication.broadcast; - localPublication.announce({ instance: INSTANCE, cost: 5n }); + const disposePrimary = serve(origin, path, () => keepPrimary.clone(), RouteInfo.normalize({ instance: INSTANCE })); + const localRoute = origin.route(path, { instance: INSTANCE, cost: 5n }); + const local = localRoute.broadcast; const request = consumer.request(path); await settle(); const first = request.active.peek(); expect(first).toBeDefined(); const restarted = new BroadcastProducer(); - const disposeRestarted = serve(origin, path, provider(restarted), Route.normalize({ instance: newer })); + const disposeRestarted = serve(origin, path, provider(restarted), RouteInfo.normalize({ instance: newer })); await settle(); expect(request.active.peek()).toBe(first); @@ -1632,10 +1739,9 @@ test("a stale takeover's refusal leaves a request whose source came back", async const path = Path.from("resumed"); const newer = Instance.parse("01900000-0000-7000-8000-000000000002"); - const localPublication = origin.createBroadcast(path); - const local = localPublication.broadcast; - localPublication.announce({ instance: INSTANCE, cost: 2n }); - const standby = wireOf(origin).receive(path, Route.normalize({ instance: INSTANCE, cost: 5n })); + const localRoute = origin.route(path, { instance: INSTANCE, cost: 2n }); + const local = localRoute.broadcast; + const standby = wireOf(origin).receive(path, RouteInfo.normalize({ instance: INSTANCE, cost: 5n })); const asked = standby.requested(); const request = consumer.request(path); await settle(); @@ -1644,14 +1750,19 @@ test("a stale takeover's refusal leaves a request whose source came back", async const restarted = new BroadcastProducer(); // Another instance, cheaper, wins new requests. - const disposeRestarted = serve(origin, path, provider(restarted), Route.normalize({ instance: newer, cost: 1n })); + const disposeRestarted = serve( + origin, + path, + provider(restarted), + RouteInfo.normalize({ instance: newer, cost: 1n }), + ); await settle(); expect(request.active.peek()).toBe(first); // The local broadcast steps away, so the standby is asked to take over; it comes back first. - localPublication.unannounce(); + localRoute.withdraw(); const takeover = await asked.next(); - localPublication.announce({ instance: INSTANCE, cost: 2n }); + localRoute.announce(); await settle(); takeover.value?.reject(new Error("too late")); await settle(); @@ -1672,7 +1783,7 @@ test("a request ends when its winning source goes, even with a standby", async ( const path = Path.from("standby"); const standby = new BroadcastProducer(); - const disposeStandby = serve(origin, path, provider(standby), Route.normalize({ cost: 5n })); + const disposeStandby = serve(origin, path, provider(standby), RouteInfo.normalize({ cost: 5n })); const winner = new BroadcastProducer(); const keepWinner = winner.consume(); const disposeWinner = serve(origin, path, () => keepWinner.clone()); @@ -1702,8 +1813,8 @@ test("a request ends when its winning source goes, even with a standby", async ( test("a refusal is terminal, never falling through to a broader route", async () => { const origin = new Producer(); const consumer = origin.consume(); - const narrow = origin.requestsRoute(Path.from("live")); - const wide = origin.requestsRoute(Path.from("")); + const narrow = origin.routePrefix(Path.from("live")); + const wide = origin.routePrefix(Path.from("")); const wideRequests = wide.requested(); const wideAsked = wideRequests.next(); @@ -1730,7 +1841,7 @@ test("a better route's refusal ends a served request", async () => { const consumer = origin.consume(); const served = new BroadcastProducer(); // One instance, so the narrower route serves the same instance and the request follows it. - const disposeWide = serve(origin, Path.from(""), provider(served), Route.normalize({ instance: INSTANCE })); + const disposeWide = serve(origin, Path.from(""), provider(served), RouteInfo.normalize({ instance: INSTANCE })); const request = consumer.request(Path.from("live/cam")); await settle(); @@ -1739,8 +1850,8 @@ test("a better route's refusal ends a served request", async () => { // The narrower route is asked while the broad one keeps serving, then says no. A // costlier sibling at the same prefix could serve, but is never asked. - const narrow = origin.requestsRoute(Path.from("live"), { instance: INSTANCE, cost: 1n }); - const sibling = origin.requestsRoute(Path.from("live"), { instance: INSTANCE, cost: 2n }); + const narrow = origin.routePrefix(Path.from("live"), { instance: INSTANCE, cost: 1n }); + const sibling = origin.routePrefix(Path.from("live"), { instance: INSTANCE, cost: 2n }); const siblingRequests = sibling.requested(); const siblingAsked = siblingRequests.next(); const { value: req } = await narrow.requested().next(); @@ -1765,14 +1876,14 @@ test("a better route's refusal ends a served request", async () => { test("a refusal from a superseded route does not end the request", async () => { const origin = new Producer(); const consumer = origin.consume(); - const wide = origin.requestsRoute(Path.from("")); + const wide = origin.routePrefix(Path.from("")); const wideRequests = wide.requested(); const request = consumer.request(Path.from("live/cam")); const { value: stale } = await wideRequests.next(); // A narrower route takes over the pending request before the broad one answers. - const narrow = origin.requestsRoute(Path.from("live")); + const narrow = origin.routePrefix(Path.from("live")); const { value: req } = await narrow.requested().next(); stale?.reject(new Error("unserved")); await settle(); @@ -1794,7 +1905,7 @@ test("a refusal from a superseded route does not end the request", async () => { test("a refusal from a route a local broadcast superseded does not end the request", async () => { const origin = new Producer(); const consumer = origin.consume(); - const wide = origin.requestsRoute(Path.from("live")); + const wide = origin.routePrefix(Path.from("live")); const wideRequests = wide.requested(); const request = consumer.request(Path.from("live/cam")); @@ -1820,7 +1931,7 @@ test("a refusal from a route a local broadcast superseded does not end the reque test("close rejects queued requests as unroutable", async () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live")); + const handle = origin.routePrefix(Path.from("live")); const waiting = handle.requested().next(); const request = origin.request(Path.from("live/cam")); const { value: req } = await waiting; @@ -1858,9 +1969,9 @@ test("announced lists full names while covering views and session advertisements const origin = new Producer(); const patterns = new Path.Patterns([Path.Pattern.parse("*.hang"), Path.Pattern.parse("**/*.chat")]); const reader = origin.scope(Path.from("project"), patterns).consume(); - const cam = origin.requestsRoute(Path.from("project/cam.hang")); - const overlap = origin.requestsRoute(Path.from("project/room/mic.hang")); - const chat = origin.requestsRoute(Path.from("project/lobby.chat")); + const cam = origin.routePrefix(Path.from("project/cam.hang")); + const overlap = origin.routePrefix(Path.from("project/room/mic.hang")); + const chat = origin.routePrefix(Path.from("project/lobby.chat")); const selected = await announcedView(reader); const covering = await announcedView(reader, undefined, { covering: true }); const all = [Path.from("cam.hang"), Path.from("lobby.chat"), Path.from("room/mic.hang")]; @@ -1868,10 +1979,10 @@ test("announced lists full names while covering views and session advertisements expect([...covering.peek().keys()].sort()).toEqual(all); expect(Array.from(wireOf(reader).advertised.peek()?.keys() ?? []).sort()).toEqual(all); - const nested = origin.requestsRoute(Path.from("project/room/lobby.chat")); + const nested = origin.routePrefix(Path.from("project/room/lobby.chat")); await new Promise((resolve) => setImmediate(resolve)); expect(selected.peek().has(Path.from("room/lobby.chat"))).toBe(true); - overlap.updateRoute({ cost: 2n }); + overlap.setCost(2n); overlap.close(); nested.close(); await new Promise((resolve) => setImmediate(resolve)); @@ -1891,7 +2002,7 @@ test("announced filters before rooting and retains lifecycle, routing and watch const interests: string[] = []; const unlisten = wireOf(origin).interests((prefix, wanted) => interests.push(`${wanted ? "+" : "-"}${prefix}`)); const source = new BroadcastProducer(); - const broad = origin.requestsRoute(Path.from("room")); + const broad = origin.routePrefix(Path.from("room")); const stream = reader.routes(); const events = stream[Symbol.asyncIterator](); const covering = await announcedView(reader, undefined, { covering: true }); @@ -1906,13 +2017,13 @@ test("announced filters before rooting and retains lifecycle, routing and watch expect(request.active.peek()).toBeDefined(); request.close(); - const exact = origin.requestsRoute(root, { instance: INSTANCE }); + const exact = origin.routePrefix(root, { instance: INSTANCE }); expect(await events.next()).toMatchObject({ value: { kind: "start", prefix: Path.empty(), captures: expect.any(Array) }, }); - exact.updateRoute({ instance: INSTANCE, cost: 2n }); + wireOf(exact).update({ instance: INSTANCE, cost: 2n }); expect(await events.next()).toMatchObject({ value: { kind: "update", route: { cost: 2n } } }); - exact.updateRoute({ instance: Instance.random() }); + wireOf(exact).update({ instance: Instance.random() }); expect(await events.next()).toMatchObject({ value: { kind: "restart" } }); exact.close(); expect(await events.next()).toMatchObject({ value: { kind: "end" } }); @@ -2054,11 +2165,11 @@ test("a rooted producer shares the table and enforces its pattern union", async await new Promise((resolve) => setImmediate(resolve)); expect(request.active.peek()).toBeDefined(); await new Promise((resolve) => setImmediate(resolve)); - expect(() => scoped.createBroadcast(Path.from("other")).broadcast).toThrow("outside the origin scope"); + expect(() => scoped.route(Path.from("other"), { hidden: true }).broadcast).toThrow("outside the origin scope"); await new Promise((resolve) => setImmediate(resolve)); expect(() => scoped.request(Path.from("room/alice/deep"))).toThrow("outside the origin scope"); await new Promise((resolve) => setImmediate(resolve)); - expect(() => scoped.requestsRoute(Path.from("other"))).toThrow("outside the origin scope"); + expect(() => scoped.routePrefix(Path.from("other"))).toThrow("outside the origin scope"); await new Promise((resolve) => setImmediate(resolve)); expect(() => scoped.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("other/**")]))).toThrow( "do not overlap", @@ -2070,7 +2181,7 @@ test("a rooted producer shares the table and enforces its pattern union", async await new Promise((resolve) => setImmediate(resolve)); expect([...(await announcedView(nested)).peek().keys()]).toEqual([Path.from("alice")]); await new Promise((resolve) => setImmediate(resolve)); - expect(() => nested.createBroadcast(Path.from("alice/deep")).broadcast).toThrow("outside the origin scope"); + expect(() => nested.route(Path.from("alice/deep"), { hidden: true }).broadcast).toThrow("outside the origin scope"); const announced = nested.routes(); await new Promise((resolve) => setImmediate(resolve)); expect(await announced.next()).toMatchObject({ kind: "start", prefix: Path.from("alice") }); @@ -2086,8 +2197,8 @@ test("closing a derived handle releases only what it registered", async () => { const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("room"))])); const wide = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.all()])); const inside = publish(scoped, Path.from("room/alice")); - const claim = scoped.requests(Path.from("room/claim")); - const ranked = wide.requests(Path.from("ranked")); + const claim = scoped.routePrefix(Path.from("room/claim")); + const ranked = wide.routePrefix(Path.from("ranked")); const peer = serve(scoped, Path.from("room/peer"), provider(new BroadcastProducer())); scoped.close(); @@ -2100,8 +2211,8 @@ test("closing a derived handle releases only what it registered", async () => { expect([...routes.keys()]).toEqual([Path.from("lobby")]); // A released handle refuses new work, so a borrower notices instead of publishing into nothing. - expect(() => scoped.createBroadcast(Path.from("room/late")).broadcast).toThrow("origin handle is closed"); - scoped.requests(Path.from("room/late")).close(); + expect(() => scoped.route(Path.from("room/late"), { hidden: true }).broadcast).toThrow("origin handle is closed"); + scoped.routePrefix(Path.from("room/late")).close(); expect((await announcedView(origin)).peek().has(Path.from("room/late"))).toBe(false); // The owner and fresh handles keep working. @@ -2123,9 +2234,8 @@ test("a broadcast a derived handle released cannot announce again", async () => const consumer = origin.consume(); const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.all()])); const path = Path.from("room"); - const publication = scoped.createBroadcast(path); + const publication = scoped.route(path); const broadcast = publication.broadcast; - publication.announce(); const held = await routed(consumer, path); scoped.close(); @@ -2200,9 +2310,8 @@ test("closing a derived handle withdraws the sessions and answers attached throu test("a scoped dynamic only serves allowed paths and presents relative requests", async () => { const origin = new Producer(); const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); - const dynamic = scoped.requestsRoute(Path.from("room")); + const dynamic = scoped.routePrefix(Path.from("room")); await new Promise((resolve) => setImmediate(resolve)); - expect(dynamic.prefix).toBe(Path.from("room")); const denied = origin.request(Path.from("tenant/room/alice/video")); await new Promise((resolve) => setImmediate(resolve)); expect(denied.active.peek()).toBeUndefined(); @@ -2215,7 +2324,7 @@ test("a scoped dynamic only serves allowed paths and presents relative requests" pending.value.serve(broadcast); await new Promise((resolve) => setImmediate(resolve)); expect(request.active.peek()).toBeDefined(); - dynamic.updateRoute({ cost: 2n }); + dynamic.setCost(2n); await new Promise((resolve) => setImmediate(resolve)); expect((await announcedView(origin)).peek().get(Path.from("tenant/room"))?.cost).toBe(2n); await requests.return?.(); @@ -2248,7 +2357,7 @@ test("a scoped dynamic is announced only to readers its scope can serve", async const origin = new Producer(); const dynamic = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/chat")])) - .requestsRoute(Path.empty()); + .routePrefix(Path.empty()); const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/chat")])); const video = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/video")])); await new Promise((resolve) => setImmediate(resolve)); @@ -2265,8 +2374,8 @@ test("a scoped dynamic is announced only to readers its scope can serve", async test("a rooted reader presents the most specific covering route", async () => { const origin = new Producer(); - const narrow = origin.requestsRoute(Path.from("room/alice"), { cost: 9n }); - const broad = origin.requestsRoute(Path.from("room"), { cost: 1n }); + const narrow = origin.routePrefix(Path.from("room/alice"), { cost: 9n }); + const broad = origin.routePrefix(Path.from("room"), { cost: 1n }); const rooted = origin.scope(Path.from("room/alice"), new Path.Patterns([Path.Pattern.all()])); await new Promise((resolve) => setImmediate(resolve)); expect((await announcedView(rooted)).peek().get(Path.empty())?.cost).toBe(9n); @@ -2280,8 +2389,8 @@ test("a rooted reader presents the most specific covering route", async () => { test("a scoped reader picks the best route its scope can see at a prefix", async () => { const origin = new Producer(); const scoped = (pattern: string) => origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse(pattern)])); - const chat = scoped("*/chat").requestsRoute(Path.empty(), { cost: 1n }); - const video = scoped("*/video").requestsRoute(Path.empty(), { cost: 5n }); + const chat = scoped("*/chat").routePrefix(Path.empty(), { cost: 1n }); + const video = scoped("*/video").routePrefix(Path.empty(), { cost: 5n }); const reader = scoped("room/video"); await new Promise((resolve) => setImmediate(resolve)); @@ -2311,10 +2420,9 @@ test("a scoped reader sees a local broadcast that only loses to a route outside const origin = new Producer(); const chat = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*/chat")])) - .requestsRoute(Path.from("room/alice"), { instance: INSTANCE, cost: 0n }); - const localPublication = origin.createBroadcast(Path.from("room/alice")); - const local = localPublication.broadcast; - localPublication.announce({ instance: INSTANCE, cost: 5n }); + .routePrefix(Path.from("room/alice"), { instance: INSTANCE, cost: 0n }); + const localRoute = origin.route(Path.from("room/alice"), { instance: INSTANCE, cost: 5n }); + const local = localRoute.broadcast; const reader = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*")])); await new Promise((resolve) => setImmediate(resolve)); @@ -2346,7 +2454,7 @@ test("broadcast handles carry the path they were created or requested at", async expect(clone?.path).toBe(Path.from("room/alice")); // A dynamic handler's standalone broadcast is named by the request, too. - const dynamic = origin.requestsRoute(Path.from("live")); + const dynamic = origin.routePrefix(Path.from("live")); const request = origin.request(Path.from("live/bob")); const pending = await dynamic.requested().next(); const served = new BroadcastProducer(); @@ -2367,9 +2475,8 @@ test("a higher epoch of a dynamic route supersedes a cheaper local broadcast of const origin = new Producer(); const path = Path.from("camera"); const instance = Instance.random(); - const localPublication = origin.createBroadcast(path); - localPublication.announce({ instance, epoch: 1n, cost: 0n }); - const dynamic = origin.requestsRoute(path, { instance, epoch: 2n, cost: 10n }); + const localRoute = origin.route(path, { instance, epoch: 1n, cost: 0n }); + const dynamic = origin.routePrefix(path, { instance, epoch: 2n, cost: 10n }); const served = new BroadcastProducer(); const requests = dynamic.requested(); const request = origin.consume().request(path); @@ -2381,7 +2488,7 @@ test("a higher epoch of a dynamic route supersedes a cheaper local broadcast of request.close(); await requests.return?.(); dynamic.close(); - localPublication.broadcast.close(); + localRoute.broadcast.close(); served.close(); origin.close(); }); @@ -2390,10 +2497,9 @@ test("a higher epoch that is not the best route still supersedes a local broadca const origin = new Producer(); const path = Path.from("camera"); const stale = Instance.random(); - const localPublication = origin.createBroadcast(path); - localPublication.announce({ instance: stale, epoch: 1n, cost: 0n }); - const newer = origin.requestsRoute(path, { instance: stale, epoch: 2n, cost: 10n }); - const other = origin.requestsRoute(path, { instance: Instance.random(), cost: 5n }); + const localRoute = origin.route(path, { instance: stale, epoch: 1n, cost: 0n }); + const newer = origin.routePrefix(path, { instance: stale, epoch: 2n, cost: 10n }); + const other = origin.routePrefix(path, { instance: Instance.random(), cost: 5n }); const served = new BroadcastProducer(); const requests = other.requested(); const request = origin.consume().request(path); @@ -2406,7 +2512,7 @@ test("a higher epoch that is not the best route still supersedes a local broadca await requests.return?.(); other.close(); newer.close(); - localPublication.broadcast.close(); + localRoute.broadcast.close(); served.close(); origin.close(); }); @@ -2415,23 +2521,22 @@ test("a publisher weighs a local broadcast against the routes it may forward onl const origin = new Producer(); const path = Path.from("camera"); const instance = Instance.random(); - const localPublication = origin.createBroadcast(path); - localPublication.announce({ instance, epoch: 1n, cost: 0n }); - const originated = origin.requestsRoute(path, { cost: 5n }); + const localRoute = origin.route(path, { instance, epoch: 1n, cost: 0n }); + const originated = origin.routePrefix(path, { cost: 5n }); // A session delivered the higher epoch, which a publisher never forwards back out. const received = wireOf(origin).receive(path, { instance, epoch: 2n, cost: 10n, hops: [PEER] }); expect(wireOf(origin.consume()).local(path, instance)).toBeDefined(); received.close(); originated.close(); - localPublication.broadcast.close(); + localRoute.broadcast.close(); origin.close(); }); test("a scope's merged covering prefixes never supersede across prefixes", async () => { const origin = new Producer(); const instance = Instance.random(); - const room = origin.requestsRoute(Path.from("room"), { instance, epoch: 2n }); - const video = origin.requestsRoute(Path.from("room/video"), { instance, epoch: 1n }); + const room = origin.routePrefix(Path.from("room"), { instance, epoch: 2n }); + const video = origin.routePrefix(Path.from("room/video"), { instance, epoch: 1n }); const reader = origin.scope(Path.from("room/video"), new Path.Patterns([Path.Pattern.all()])).consume(); await settle(); const table = wireOf(reader).advertised.peek(); @@ -2448,14 +2553,14 @@ test("supersession only counts routes the reader can see", async () => { const instance = Instance.random(); const video = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/video")])) - .requestsRoute(Path.empty(), { instance, epoch: 1n }); + .routePrefix(Path.empty(), { instance, epoch: 1n }); const live = await announcedView(origin, Path.Pattern.parse("room/video"), { covering: true }); await settle(); expect(live.peek().size).toBe(1); // A higher epoch the reader cannot see never hides the route it can. const chat = origin .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/chat")])) - .requestsRoute(Path.empty(), { instance, epoch: 2n }); + .routePrefix(Path.empty(), { instance, epoch: 2n }); await settle(); expect([...live.peek().values()].map((route) => route.epoch)).toEqual([1n]); chat.close(); @@ -2470,9 +2575,8 @@ test("the newest epoch of an instance is announced in place over a cheaper one", const announced = consumer.routes(); const instance = Instance.random(); - const oldPublication = origin.createBroadcast(path); - const old = oldPublication.broadcast; - oldPublication.announce({ instance, epoch: 1n, cost: 1n }); + const oldRoute = origin.route(path, { instance, epoch: 1n, cost: 1n }); + const old = oldRoute.broadcast; expect(await announced.next()).toMatchObject({ prefix: path, kind: "start" }); // A higher epoch of the same instance supersedes it despite the cost: the same stream. @@ -2498,14 +2602,14 @@ test("demand held across an instance change asks again, unless it named the old const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("live/cam"); - const handle = origin.requestsRoute(Path.from("live"), { instance: INSTANCE }); + const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); const it = handle.requested(); const pinned = wireOf(consumer).demand(path, INSTANCE); const unpinned = wireOf(consumer).demand(path); const { value: req } = await it.next(); // Another instance now serves the route: the old handler's answer is for the old one. - handle.updateRoute({ ...handle.route, instance: NEWER }); + wireOf(handle).update({ ...wireOf(handle).info, instance: NEWER }); req.serve(new BroadcastProducer().consume()); await expect(pinned).rejects.toThrow("unroutable"); @@ -2527,13 +2631,13 @@ test("a demand refused across an instance change does not lose the carried deman const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("live/cam"); - const handle = origin.requestsRoute(Path.from("live"), { instance: INSTANCE }); + const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); const it = handle.requested(); const unpinned = wireOf(consumer).demand(path); const pinned = wireOf(consumer).demand(path, INSTANCE); await it.next(); - handle.updateRoute({ ...handle.route, instance: NEWER }); + wireOf(handle).update({ ...wireOf(handle).info, instance: NEWER }); await expect(pinned).rejects.toThrow("unroutable"); // The replacement's refusal reaches the demand that carried over. (await it.next()).value?.reject(new Error("gone")); @@ -2547,14 +2651,14 @@ test("a demand refused across an instance change does not lose the carried deman test("demand held across an instance round trip still asks again", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const handle = origin.requestsRoute(Path.from("live"), { instance: INSTANCE }); + const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); const it = handle.requested(); const demanded = wireOf(origin.consume()).demand(path); const { value: req } = await it.next(); // Released by the first change; the instance coming back does not undo that. - handle.updateRoute({ ...handle.route, instance: NEWER }); - handle.updateRoute({ ...handle.route, instance: INSTANCE }); + wireOf(handle).update({ ...wireOf(handle).info, instance: NEWER }); + wireOf(handle).update({ ...wireOf(handle).info, instance: INSTANCE }); req.serve(new BroadcastProducer().consume()); const next = new BroadcastProducer(); @@ -2573,18 +2677,18 @@ test("demand held across an instance round trip still asks again", async () => { test("re-pricing from the current route keeps the instance", () => { const origin = new Producer(); - const handle = origin.requestsRoute(Path.from("live"), { instance: INSTANCE, cost: 5n }); - handle.updateRoute({ ...handle.route, cost: 1n }); - expect(handle.route).toEqual(Route.normalize({ instance: INSTANCE, cost: 1n })); + const handle = origin.routePrefix(Path.from("live"), { instance: INSTANCE, cost: 5n }); + handle.setCost(1n); + expect(wireOf(handle).info).toEqual(RouteInfo.normalize({ instance: INSTANCE, cost: 1n })); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - expect(broadcastPublication.route).toBeUndefined(); - broadcastPublication.announce({ instance: INSTANCE, cost: 5n }); - broadcastPublication.announce({ ...broadcastPublication.route, cost: 1n }); - expect(broadcastPublication.route).toEqual(Route.normalize({ instance: INSTANCE, cost: 1n })); - broadcastPublication.unannounce(); - expect(broadcastPublication.route).toBeUndefined(); + const broadcastRoute = origin.route(Path.from("room"), { hidden: true, instance: INSTANCE, cost: 5n }); + const broadcast = broadcastRoute.broadcast; + expect(wireOf(broadcastRoute).info).toBeUndefined(); + broadcastRoute.announce(); + broadcastRoute.setCost(1n); + expect(wireOf(broadcastRoute).info).toEqual(RouteInfo.normalize({ instance: INSTANCE, cost: 1n })); + broadcastRoute.withdraw(); + expect(wireOf(broadcastRoute).info).toBeUndefined(); broadcast.close(); handle.close(); @@ -2595,9 +2699,8 @@ test("a peer naming another instance is refused rather than served a different i const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); - const localPublication = origin.createBroadcast(path); - const local = localPublication.broadcast; - localPublication.announce({ instance: INSTANCE }); + const localRoute = origin.route(path, { instance: INSTANCE }); + const local = localRoute.broadcast; expect(wireOf(consumer).local(path, INSTANCE)).toBeDefined(); const other = Instance.parse("01900000-0000-7000-8000-000000000002"); @@ -2611,18 +2714,16 @@ test("a peer naming another instance is refused rather than served a different i test("a newer instance ends pinned and unpinned requests on the replaced publisher alike", () => { const origin = new Producer(); const path = Path.from("room"); - const first = origin.createBroadcast(path); - first.announce({ instance: INSTANCE }); + const first = origin.route(path, { instance: INSTANCE }); const reader = origin.consume(); const pinned = reader.request(path, { instance: INSTANCE }); const unpinned = reader.request(path); expect(pinned.active.peek()?.instance).toBe(INSTANCE); expect(unpinned.active.peek()?.instance).toBe(INSTANCE); const replacement = Instance.parse("01900000-0000-7000-8000-000000000002"); - const second = origin.createBroadcast(path); - second.announce({ instance: replacement }); - expect(() => first.announce({ instance: INSTANCE })).toThrow("broadcast is closed"); - first.unannounce(); + origin.route(path, { instance: replacement }); + expect(() => first.announce()).toThrow("broadcast is closed"); + first.withdraw(); expect(pinned.active.peek()).toBeUndefined(); // The replaced publisher closed: the unpinned request ends rather than move to the newer one. expect(unpinned.active.peek()).toBeUndefined(); @@ -2642,12 +2743,12 @@ test("a request held across an instance change asks again and never takes the ol const origin = new Producer(); const prefix = Path.from("room"); const path = Path.from("room/cam"); - const dynamic = origin.requestsRoute(prefix, { instance: INSTANCE }); + const dynamic = origin.routePrefix(prefix, { instance: INSTANCE }); const unpinned = origin.consume().request(path); const pinned = origin.consume().request(path, { instance: INSTANCE }); const requests = dynamic.requested(); const old = (await requests.next()).value; - dynamic.updateRoute({ ...dynamic.route, instance: NEWER }); + wireOf(dynamic).update({ ...wireOf(dynamic).info, instance: NEWER }); const current = origin.consume().request(path, { instance: NEWER }); old.serve(new BroadcastProducer().consume()); await settle(); @@ -2675,9 +2776,9 @@ test.each([ const origin = new Producer(); const prefix = Path.from("live"); const path = Path.from("live/cam"); - const dynamic = origin.requestsRoute(prefix, { instance: named }); + const dynamic = origin.routePrefix(prefix, { instance: named }); // A claim naming no instance serves one of its own. - const from = dynamic.route.instance; + const from = dynamic.instance; expect(from).toBeDefined(); if (named !== undefined) expect(from).toBe(named); const to = NEWER; @@ -2698,7 +2799,7 @@ test.each([ expect((await sticky?.recvGroup())?.sequence).toBe(0n); // A re-price at the same instance keeps every handle, and the handler is not asked again. - dynamic.updateRoute({ ...dynamic.route, cost: 5n }); + dynamic.setCost(5n); expect(await announced.next()).toMatchObject({ prefix, kind: "update" }); expect(held.active.peek()).toBe(resolved); const same = origin.consume().request(path); @@ -2707,7 +2808,7 @@ test.each([ const pinned = origin.consume().request(path, { instance: from }); // Another instance is another stream: resolved requests keep the old one. - dynamic.updateRoute({ ...dynamic.route, instance: to }); + wireOf(dynamic).update({ ...wireOf(dynamic).info, instance: to }); expect(await announced.next()).toMatchObject({ prefix, kind: "restart" }); await settle(); expect(held.active.peek()).toBe(resolved); @@ -2762,14 +2863,14 @@ test.each([ test("an instance update does not retain an answer that already closed", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.requestsRoute(Path.from("live"), { instance: INSTANCE }); + const dynamic = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new BroadcastProducer(); (await requests.next()).value.serve(old); old.close(); // The server's closure callback has not removed the answer yet. - dynamic.updateRoute({ instance: NEWER }); + wireOf(dynamic).update({ instance: NEWER }); try { expect(held.closed.peek()).toBeInstanceOf(Error); expect(held.active.peek()).toBeUndefined(); @@ -2790,13 +2891,13 @@ test("an instance update does not retain an answer that already closed", async ( test("an unread resolved request retains its broadcast until its last handle closes", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.requestsRoute(Path.from("live"), { instance: INSTANCE }); + const dynamic = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new SessionBroadcast(); (await requests.next()).value.serve(old); // Change instances before the active getter or its subscription can clone the answer. - dynamic.updateRoute({ instance: NEWER }); + wireOf(dynamic).update({ instance: NEWER }); expect(old.closed.peek()).toBeUndefined(); expect(held.active.peek()?.instance).toBe(INSTANCE); held.close(); @@ -2810,7 +2911,7 @@ test("an unread resolved request retains its broadcast until its last handle clo test("a session's re-request after an instance update asks the handler again", async () => { const origin = new Producer(); const path = Path.from("live/cam"); - const dynamic = origin.requestsRoute(Path.from("live"), { instance: INSTANCE }); + const dynamic = origin.routePrefix(Path.from("live"), { instance: INSTANCE }); const requests = dynamic.requested(); const wire = wireOf(origin.consume()); @@ -2819,7 +2920,7 @@ test("a session's re-request after an instance update asks the handler again", a (await requests.next()).value.serve(old); expect(await before).toBeDefined(); - dynamic.updateRoute({ ...dynamic.route, instance: NEWER }); + wireOf(dynamic).update({ ...wireOf(dynamic).info, instance: NEWER }); const after = wire.demand(path); const next = new BroadcastProducer(); const track = next.createTrack("video"); @@ -2840,8 +2941,8 @@ test("an update does not override route selection: another claim at the instance const origin = new Producer(); const prefix = Path.from("live"); const path = Path.from("live/cam"); - const first = origin.requestsRoute(prefix, { instance: INSTANCE, cost: 1n }); - const second = origin.requestsRoute(prefix, { instance: INSTANCE, cost: 5n }); + const first = origin.routePrefix(prefix, { instance: INSTANCE, cost: 1n }); + const second = origin.routePrefix(prefix, { instance: INSTANCE, cost: 5n }); const firstRequests = first.requested(); const secondRequests = second.requested(); @@ -2854,7 +2955,7 @@ test("an update does not override route selection: another claim at the instance // The first claim moves to another instance priced above the second, which still serves // the old one: the resolved request keeps its answer, and new requests go through the // second claim. - first.updateRoute({ ...first.route, instance: NEWER, cost: 9n }); + wireOf(first).update({ ...wireOf(first).info, instance: NEWER, cost: 9n }); const fresh = origin.consume().request(path); const replacement = new BroadcastProducer(); const track = replacement.createTrack("video"); @@ -2878,14 +2979,13 @@ test("an update does not override route selection: another claim at the instance origin.close(); }); -test("a route naming no instance resolves a handle under the one its publication minted", () => { +test("a route naming no instance resolves a handle under the one it minted", () => { const origin = new Producer(); const path = Path.from("room"); - const broadcast = origin.createBroadcast(path); - broadcast.announce(); + const broadcast = origin.route(path); const request = origin.consume().request(path); - expect(broadcast.route?.instance).toBeDefined(); - expect(request.active.peek()?.instance).toBe(broadcast.route?.instance); + expect(broadcast.instance).toBeDefined(); + expect(request.active.peek()?.instance).toBe(broadcast.instance); request.close(); origin.close(); }); @@ -2893,19 +2993,18 @@ test("a route naming no instance resolves a handle under the one its publication test("public announcements saturate static costs and refuse invalid cost shapes", async () => { const origin = new Producer(); const path = Path.from("priced"); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; + const broadcastRoute = origin.route(path, { hidden: true }); + const broadcast = broadcastRoute.broadcast; const view = await announcedView(origin); - broadcastPublication.announce({ cost: 2n ** 64n - 1n }); + broadcastRoute.setCost(2n ** 64n - 1n); + broadcastRoute.announce(); await settle(); await new Promise((resolve) => setImmediate(resolve)); expect(view.peek().get(path)?.cost).toBe(2n ** 62n - 1n); await new Promise((resolve) => setImmediate(resolve)); - expect(() => broadcastPublication.announce({ cost: -1n })).toThrow(RangeError); + expect(() => broadcastRoute.setCost(-1n)).toThrow(RangeError); await new Promise((resolve) => setImmediate(resolve)); - expect(() => broadcastPublication.announce({ cost: { warm: 1n, cold: 2n } } as unknown as Route)).toThrow( - RangeError, - ); + expect(() => broadcastRoute.setCost({ warm: 1n, cold: 2n } as unknown as Cost)).toThrow(RangeError); broadcast.close(); origin.close(); }); @@ -2915,14 +3014,14 @@ test("published names refuse navigation segments", () => { try { for (const name of ["room/./cam", "room/../cam", ".", ".."]) { const path = Path.from(name); - expect(() => origin.createBroadcast(path).broadcast).toThrow(); - expect(() => origin.requests(path)).toThrow(); - expect(() => wireOf(origin).receive(path, Route.default)).toThrow(); + expect(() => origin.route(path, { hidden: true }).broadcast).toThrow(); + expect(() => origin.routePrefix(path)).toThrow(); + expect(() => wireOf(origin).receive(path, RouteInfo.default)).toThrow(); } const scoped = origin.scope(Path.from("room/.."), new Path.Patterns([Path.Pattern.all()])); - expect(() => scoped.createBroadcast(Path.from("cam")).broadcast).toThrow(); + expect(() => scoped.route(Path.from("cam"), { hidden: true }).broadcast).toThrow(); for (const path of ["room/.pro/cam", "room/a..b", "room/cam"]) { - origin.createBroadcast(Path.from(path)).broadcast.close(); + origin.route(Path.from(path), { hidden: true }).broadcast.close(); } } finally { origin.close(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index ca062735965..7fc4a48be2b 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -13,9 +13,10 @@ import { Derived, type Dispose, type GetPromise, type Getter, Once, race, Signal } from "@moq/signals"; import type * as Announce from "./announce.ts"; import * as announce from "./announced.ts"; +import type { Credential } from "./auth.ts"; import * as broadcast from "./broadcast.ts"; import { StreamCode, StreamError } from "./error.ts"; -import { Route, routesEqual } from "./hop.ts"; +import { type Cost, RouteInfo, routesEqual } from "./hop.ts"; import * as Instance from "./instance.ts"; import { compareRouteCandidates, @@ -43,7 +44,7 @@ import { wireOf, } from "./wire.ts"; -export type { Cost, Hop, Route } from "./hop.ts"; +export type { Cost, Hop, RouteInfo } from "./hop.ts"; export { HopSchema, isAnonymous } from "./hop.ts"; /** The rooted permissions shared by a handle and every route it inserts. */ @@ -243,10 +244,8 @@ class CoveringRoot { * A refusal is terminal, even while another source still serves: the slot ends, every handle * closes with the handler's error, and the slot leaves the table, so the next request asks * afresh. A refusal never falls through to a broader prefix or another advertiser. - * - * @internal */ -export interface RequestSlot { +interface RequestSlot { /** Open plain handles that can use a peer without discovery. */ blind: number; answer?: broadcast.Consumer; @@ -281,15 +280,13 @@ interface Resolution { * The preferred entry per prefix is the one requests resolve through. An originated * entry is forwarded by sessions; a received one is not, so a shared origin * cannot echo a peer's announcements back to it. - * - * @internal */ -export interface RouteEntry { +interface RouteEntry { readonly identity: object; readonly scope: Scope; /** The paths the entry may serve beneath its prefix, when its producer is scoped. */ readonly claim?: Path.Patterns; - readonly route: Signal; + readonly route: Signal; readonly originated: boolean; readonly server?: ServeState; } @@ -321,7 +318,7 @@ function preferredEntry( prefix: Path.Valid, entries: readonly RouteEntry[], skip?: (entry: RouteEntry) => boolean, - others: readonly Route[] = [], + others: readonly RouteInfo[] = [], ): RouteEntry | undefined { const eligible = entries.filter((entry) => !skip?.(entry)); // `others` compete at the prefix too, such as a broadcast announced here at the path. @@ -359,7 +356,7 @@ function unroutable(): StreamError { // A selected handler refused these requests; a coalesced route gap can have the same error. const refusedRequests = new WeakSet>(); -/** A served route from {@link Producer.requests}: the queue a handler drains. */ +/** A prefix route's queue of requests, from {@link Producer.routePrefix}: what a handler drains. */ class ServeState { readonly root: Path.Valid; /** The prefix the route claims; every served path is at or beneath it. */ @@ -531,7 +528,7 @@ class OriginState { // directions echo-free. created: Map | undefined = new Map(); local = new VersionedSignal | undefined>(new Map()); - advertisedLocal = new VersionedSignal | undefined>(new Map()); + advertisedLocal = new VersionedSignal | undefined>(new Map()); routes = new VersionedSignal | undefined>(new Map()); #snapshotVersion = ""; @@ -572,7 +569,7 @@ class OriginState { for (const [path, front] of this.local.peek() ?? []) { const local = { identity: front, - route: advertised?.get(path) ?? Route.default, + route: advertised?.get(path) ?? RouteInfo.default, exact: true, }; const list = out.get(path); @@ -591,6 +588,10 @@ class OriginState { // republish diffs as retract-then-announce and a re-price as another active. originated = new Signal(new Map()); + // Every open prefix route's server, advertised or not, so the origin's close ends each + // handler loop even for a route that is hidden or withdrawn. + servers = new Set(); + // Broadcasts materialized from a served route, keyed by exact path. Shared by every // request for the path so repeats reuse one accept; dropped (and closed) when the // providing route goes away or the last request releases it. @@ -619,6 +620,9 @@ class OriginState { // retain their old broadcast without participating in routing or same-instance failover. retained = new WeakSet(); + // The sessions withholding hidden broadcasts from their peer; see `hooks.withholdHidden`. + withheld = new Signal(0); + // How many sessions are attached, and how many of those support broadcast discovery. // What backs the public `discovery` getter. sessions = new Signal({ total: 0, discovery: 0 }); @@ -1234,25 +1238,54 @@ export interface Reader { * @public */ export interface Table extends Reader { - /** Create an unannounced broadcast at `path`; see {@link Producer.createBroadcast}. */ - createBroadcast(path: Path.Valid): Publication; - - /** Advertise a prefix and serve requests under it; see {@link Producer.requests}. */ - requests(prefix: Path.Valid): Requests; - - /** Insert a route with protocol metadata. @internal */ - requestsRoute( - prefix: Path.Valid, - route?: - | Route - | { - instance?: Route["instance"]; - epoch?: Route["epoch"]; - hops?: Route["hops"]; - cost?: Route["cost"]; - auth?: Route["auth"]; - }, - ): Requests; + /** Create a route at exactly `path`, owning a new broadcast; see {@link Producer.route}. */ + route(path: Path.Valid, options?: RouteOptions): Route; + + /** Create a route over `prefix` that yields requests; see {@link Producer.routePrefix}. */ + routePrefix(prefix: Path.Valid, options?: RouteOptions): Route; +} + +/** + * How an application advertises a {@link Route}: its visibility at creation, its price, its + * credential, and, rarely, a stable instance. + * + * The default announces at once, at cost 0, under the session's scope, serving a fresh random + * instance minted when the route is created. + */ +export interface RouteOptions { + /** + * Create the route hidden: it exists for nobody until {@link Route.announce}. For a + * publisher that fills its catalog first, or answers a negotiation, before readers may find it. + */ + hidden?: boolean; + /** + * What pulling content via this route costs: lower wins among routes covering the same + * prefix and anonymity. A publisher prices content it already produces at zero, and content + * it would have to start producing on demand higher. + */ + cost?: Cost; + /** + * The credential the advertising request carries, from `auth.add()`. Only a session built + * on the handle that issued it presents it, as the token on its moq-transport + * PUBLISH_NAMESPACE; every other session advertises the route under its own scope. + */ + auth?: Credential; + /** + * Serve this stable instance in place of a freshly minted one. + * + * Only for a publisher that continues the instance's sequence space across restarts, + * announcing a higher {@link epoch} each time, such as a counter persisted on disk. Readers + * stitch every route of one instance together, so a publisher that restarts its group + * sequences under an old instance splices unrelated groups into their tracks. The highest + * epoch wins over every older one of the instance, cheaper or not. Naming an instance grants + * nothing: publishing still needs the path's grant. + * + * Do not start a named instance with `~`: that marks one a relay minted (see + * {@link Instance.isMinted}), which signing refuses to bind and a catalog's instance check skips. + */ + instance?: Instance.Valid; + /** Which incarnation of {@link instance} this is; raise it on each restart. Ignored without an instance. */ + epoch?: bigint; } /** Options for resolving one publisher instance with {@link Consumer.request}. */ @@ -1267,8 +1300,12 @@ export interface FollowOptions { instance?: Instance.Valid; } +// Route constructors, assigned in the classes' static blocks so neither class exposes one. +let makeRoute: (control: RouteControl) => Route; +let exactRoute: (producer: Producer, path: Path.Valid, options: RouteOptions, request: Request) => Route; + /** - * The write side of an origin: create broadcasts by path and advertise them. + * The write side of an origin: route broadcasts into it by path and advertise them. * * Independent of any connection. A connection given this origin (via its `publish` option) * announces and serves the table's originated advertisements for as long as the session @@ -1276,10 +1313,10 @@ export interface FollowOptions { * the origin down. A reconnecting session re-announces the table on each attach, so * advertisements made while offline surface on the next connection. * - * Create a broadcast, populate its tracks, then {@link Publication.announce}: an - * exact-path subscribe before the tracks exist is refused, and nobody can see or reach a - * broadcast until it announces. To publish broadcasts on demand instead, claim a prefix with - * {@link requests} and answer the requests beneath it. + * Create a {@link route} at a path and fill its broadcast; create it hidden and + * {@link Route.announce} it once its first tracks exist, since nobody can see or reach a hidden + * route. To publish broadcasts on demand instead, claim a prefix with {@link routePrefix} and + * answer the requests beneath it. * * @public */ @@ -1297,6 +1334,11 @@ export class Producer implements Table { // identity across reads. #reader = makeConsumer(this.#state, this.#scope); + static { + hooks.hiddenWithheld = (origin) => origin.#state.withheld; + exactRoute = (producer, path, options, request) => producer.#exact(path, options, request); + } + constructor() { const thisProducer = this; registerWire(this, { @@ -1331,7 +1373,7 @@ export class Producer implements Table { return thisProducer.#requests; }, changed: () => this.#changed(), - blind: (slot) => this.#state.blind(slot), + blind: (slot) => this.#state.blind(slot as RequestSlot), answer: (path, front) => this.#answer(this.#scope.path(path), front), routes: (path) => wireOf(this.#reader).routes(path), }); @@ -1361,52 +1403,48 @@ export class Producer implements Table { } /** - * Create a broadcast at `path`, returning its publication handle. + * Create a route at exactly `path`, owning a new broadcast to fill. + * + * This is how local content enters an origin. Fill the broadcast through + * {@link Route.broadcast}. The route serves a fresh random instance, minted here and kept + * for its lifetime, across {@link Route.withdraw} and {@link Route.announce}, since its + * sequences continue: a new route is a new stream that never splices into an old one. A + * publisher that continues one sequence space across restarts may name a stable instance + * instead ({@link RouteOptions.instance}). * - * The broadcast exists for nobody until {@link Publication.announce}: announce - * streams skip it and requests for its path find nothing, on this origin exactly as at - * a peer. Announce once its tracks exist; {@link Publication.unannounce} - * withdraws it from everyone again. + * Announced at once unless created {@link RouteOptions.hidden | hidden}. A hidden route + * exists for nobody until {@link Route.announce}: announce streams skip it and a request + * for its path finds nothing, on this origin exactly as at a peer. Create it hidden when + * the tracks a subscriber needs first, such as a catalog, are not there yet. To serve paths + * on demand without publishing each one, use {@link routePrefix}. * - * Close the producer to drop it. Creating a path again supersedes the previous - * broadcast: the origin retracts and closes the old one, and tracks already subscribed - * keep serving until they end. The broadcast serves a fresh random {@link Route.instance}, - * kept across announce, unannounce and re-announce, so a restart is a new stream rather - * than a splice into the old one; at the same instance, a local broadcast competes on cost - * and wins ties. A publisher that continues one sequence space across restarts may name a - * stable instance instead. + * Closing the route retracts it and closes its broadcast, and so does closing the + * broadcast. Creating a path again supersedes the previous route: the origin retracts it + * and closes its broadcast, and tracks already subscribed keep serving until they end. At + * the same instance, a local broadcast competes on cost and wins ties. */ - createBroadcast(path: Path.Valid): Publication { + route(path: Path.Valid, options: RouteOptions = {}): Route { + return this.#exact(path, options); + } + + /** An exact route at `path`, answering `request` on its first announce when it is a job's. */ + #exact(path: Path.Valid, options: RouteOptions, request?: Request): Route { path = this.#scope.path(path); if (this.#borrowed?.closed) throw new Error("origin handle is closed"); publishPath(path); const created = this.#state.created; if (!created) throw new Error("origin is closed"); + // Normalized first, so a bad cost throws before anything is created. + let info = RouteInfo.normalize({ + instance: options.instance ?? Instance.random(), + epoch: options.instance === undefined ? 0n : options.epoch, + cost: options.cost, + auth: options.auth, + }); const producer = new broadcast.Producer(); hooks.stampPath(producer, path); const front = producer.consume(); - // The publication's own stream, kept across announce, unannounce and re-announce since - // its sequences continue. A route naming another instance replaces it. - let instance = Instance.random(); - let epoch = 0n; - - const announcer: broadcast.Announcer = { - announce: (route) => { - if (route.instance !== undefined) { - instance = route.instance; - epoch = route.epoch ?? 0n; - } - this.#advertiseExact(path, front, { ...route, instance, epoch }); - }, - unannounce: () => this.#retractExact(path, front), - route: () => - this.#state.local.peek()?.get(path) === front - ? this.#state.advertisedLocal.peek()?.get(path) - : undefined, - }; - hooks.attachAnnouncer(producer, announcer); - const entry = { producer, front }; const previous = created.get(path); created.set(path, entry); @@ -1415,20 +1453,71 @@ export class Producer implements Table { retire(previous); } - // Drop it when the broadcast closes, unless a recreate already replaced it: a stale - // broadcast closing must not unpublish the live one. Clearing `created` also stops a - // later announce. + // The request a job's route answers when it first announces. + let pending = request; + // Drop the route when its broadcast closes, unless a recreate already replaced it: a + // stale broadcast closing must not unpublish the live one. Clearing `created` also stops + // a later announce. A job's unanswered request can never be answered now. const release = this.#hold(() => { + stop(); this.#retractExact(path, front); - if (this.#state.created?.get(path) === entry) this.#state.created.delete(path); + if (created.get(path) === entry) created.delete(path); retire(entry); + const job = pending; + pending = undefined; + if (job) rejectJob(job, unroutable()); }); - void front.closed.then(release); + // Synchronous, so a request made right after the broadcast closes finds it gone. + const stop = hooks.onClosed(producer, release); + + const announced = () => this.#state.local.peek()?.get(path) === front; + const announce = () => { + if (producer.closed.peek() !== undefined) throw new Error("broadcast is closed"); + const job = pending; + if (!job) { + this.#advertiseExact(path, front, info); + return; + } + pending = undefined; + try { + // A request released meanwhile can never be answered, so this route never announces. + if (!jobPending(job)) throw unroutable(); + this.#advertiseExact(path, front, info); + } catch (err) { + release(); + rejectJob(job, err instanceof Error ? err : new Error(String(err))); + throw err; + } + answerJob(job); + }; + const change = (next: RouteInfo) => { + if (producer.closed.peek() !== undefined) throw new Error("broadcast is closed"); + if (!this.#state.created) throw new Error("origin is closed"); + info = next; + if (announced()) this.#advertiseExact(path, front, info); + }; - return makePublication(producer, announcer); + const route = makeRoute({ + broadcast: producer, + instance: () => info.instance as Instance.Valid, + announce, + withdraw: () => this.#retractExact(path, front), + announced, + change: (update) => change(RouteInfo.normalize({ ...info, ...update })), + requested: async function* () {}, + close: release, + info: () => (announced() ? this.#state.advertisedLocal.peek()?.get(path) : undefined), + update: (next) => { + const named = RouteInfo.normalize(next); + info = named.instance === undefined ? { ...named, instance: info.instance, epoch: info.epoch } : named; + announce(); + }, + }); + if (!options.hidden) announce(); + return route; } - #advertiseExact(path: Path.Valid, front: broadcast.Consumer, route: Route): void { + #advertiseExact(path: Path.Valid, front: broadcast.Consumer, route: RouteInfo): void { if (!this.#state.local.peek()) throw new Error("origin is closed"); if (this.#state.created?.get(path)?.front !== front) throw new Error("broadcast is closed"); // Both maps move together, so every reader sees the broadcast and its route at once. @@ -1455,60 +1544,39 @@ export class Producer implements Table { } /** - * Advertise `prefix` and serve the requests beneath it. + * Create a route over `prefix` that yields the requests beneath it. + * + * A route is always a prefix: it claims `prefix` and every path beneath it (the empty + * prefix claims every path). A service that only serves some of them advertises the + * covering prefix and rejects the rest as they are requested; consumers narrow with a + * {@link Path.Pattern} locally. The route is priced, credentialed and shown like an exact + * one, and forwarded by sessions while it is visible. * - * A route is always a prefix: it claims `prefix` and every path beneath it (the - * empty prefix claims every path). A service that only serves some of them - * advertises the covering prefix and rejects the rest as they are requested; - * consumers narrow with a {@link Path.Pattern} locally. The advertisement is - * visible to {@link Consumer.announced} and forwarded by sessions for as long as - * the returned {@link Requests} lives. A consumer resolving a path under it that - * no announced local broadcast wins is handed to the handle as a {@link Request}. + * A consumer resolving a path under it that no announced local broadcast wins is handed to + * {@link Route.requested} as a {@link Request}: answer it by serving a broadcast already held + * ({@link Request.serve}), under this route's instance, or by creating the path's own exact + * route ({@link Request.route}), under a fresh instance, so restarting one job never splices + * into a copy of the last. A publisher that knows its broadcasts creates each one's exact + * {@link route} instead, so subscribers can enumerate them. */ - requests(prefix: Path.Valid): Requests { - return this.#insertRoute(prefix, Route.default, true, true); - } - - /** Insert an originated claim with route metadata that answers through `Request.serve`, for tests and benches. @internal */ - requestsRoute( - prefix: Path.Valid, - route: - | Route - | { - instance?: Route["instance"]; - epoch?: Route["epoch"]; - hops?: Route["hops"]; - cost?: Route["cost"]; - auth?: Route["auth"]; - } = Route.default, - ): Requests { - return this.#insertRoute(prefix, Route.normalize(route), true, false); + routePrefix(prefix: Path.Valid, options: RouteOptions = {}): Route { + const info = RouteInfo.normalize({ + instance: options.instance, + epoch: options.epoch, + cost: options.cost, + auth: options.auth, + }); + return this.#serving(prefix, info, true, true, !options.hidden); } - /** - * Land a route a peer announced, served through the returned handle. Same as - * {@link requests} but not originated, so a session never announces it back. - * - * @internal - */ - #receive( - prefix: Path.Valid, - route: - | Route - | { - instance?: Route["instance"]; - epoch?: Route["epoch"]; - hops?: Route["hops"]; - cost?: Route["cost"]; - auth?: Route["auth"]; - } = Route.default, - ): Requests { - return this.#insertRoute(prefix, Route.normalize(route), false, false); - } - - #insertRoute(prefix: Path.Valid, route: Route, originated: boolean, publishes: boolean): Requests { + /** Land a route a peer announced, taken as given. Not originated, so a session never announces it back. */ + #receive(prefix: Path.Valid, route?: Partial): Route { + return this.#serving(prefix, RouteInfo.normalize(route), false, false, true); + } + + #serving(prefix: Path.Valid, route: RouteInfo, originated: boolean, publishes: boolean, visible: boolean): Route { prefix = this.#scope.prefix(prefix); - // A handle that names no instance serves one of its own. + // A route that names no instance serves one of its own. if (route.instance === undefined) route = { ...route, instance: Instance.random(), epoch: 0n }; publishPath(prefix); const server = new ServeState(this.#scope.root, prefix); @@ -1523,40 +1591,98 @@ export class Producer implements Table { }; server.onReject = (path, err) => this.#state.refuse(path, entry, err); - let closed = this.#borrowed?.closed ?? false; - if (!closed) { + let inserted = false; + let ended = false; + // Closing the origin drops the whole table without visiting each route, so a route + // still marked inserted learns of it here, matching an exact route's "origin is closed". + const open = () => { + if (ended) throw new Error("route is closed"); + if (this.#state.routes.peek()) return; + inserted = false; + throw new Error("origin is closed"); + }; + const insert = () => { + open(); + if (inserted) return; this.#state.routes.mutate((routes) => { - if (!routes) { - closed = true; - return; - } - const entries = routes.get(prefix); + const entries = routes?.get(prefix); if (entries) entries.unshift(entry); - else routes.set(prefix, [entry]); + else routes?.set(prefix, [entry]); }); - } - if (closed) { + inserted = true; + this.#state.rebuildOriginated(); + this.#state.refreshPrefix(prefix); + }; + // Take the entry out of the table, then end (or renew) the server before the requests + // beneath it reroute, so none of them resolves through an answer it already gave. + const remove = (server: () => void) => { + if (inserted) { + inserted = false; + this.#state.routes.mutate((routes) => { + const entries = routes?.get(prefix); + if (!entries) return; + const index = entries.indexOf(entry); + if (index < 0) return; + entries.splice(index, 1); + if (entries.length === 0) routes?.delete(prefix); + }); + } + server(); + this.#state.rebuildOriginated(); + this.#state.refreshPrefix(prefix); + }; + const release = this.#hold(() => { + ended = true; + this.#state.servers.delete(server); + remove(() => server.close()); + }); + // A route made after its origin (or this handle) closed is born retracted. + if (this.#borrowed?.closed || !this.#state.routes.peek()) { + ended = true; server.close(); - return makeDynamic(prefix, entry, this.#state, () => {}, publishes ? this : undefined); + } else { + this.#state.servers.add(server); + if (visible) insert(); } - this.#state.rebuildOriginated(); - this.#state.refreshPrefix(prefix); - const retract = this.#hold(() => { - this.#state.routes.mutate((routes) => { - const entries = routes?.get(prefix); - if (!entries) return; - const index = entries.indexOf(entry); - if (index < 0) return; - entries.splice(index, 1); - if (entries.length === 0) routes?.delete(prefix); - }); - server.close(); + const update = (next: RouteInfo) => { + open(); + const previous = entry.route.peek().instance; + const restart = inserted && previous !== next.instance; + if (restart) this.#state.restart(entry); + entry.route.set(next); + if (previous !== next.instance) server.reset(); + if (!inserted) return; this.#state.rebuildOriginated(); this.#state.refreshPrefix(prefix); - }); + this.#state.routes.mutate(() => {}); + }; - return makeDynamic(prefix, entry, this.#state, retract, publishes ? this : undefined); + const publisher = publishes ? this : undefined; + return makeRoute({ + instance: () => entry.route.peek().instance as Instance.Valid, + announce: insert, + withdraw: () => { + if (ended || !inserted) return; + remove(() => server.reset()); + }, + announced: () => inserted && this.#state.routes.peek() !== undefined, + change: (changed) => update(RouteInfo.normalize({ ...entry.route.peek(), ...changed })), + requested: () => serveRequests(server, publisher), + close: release, + info: () => (inserted && this.#state.routes.peek() ? entry.route.peek() : undefined), + update: (next) => { + // A route naming no instance keeps the one it serves. + const named = RouteInfo.normalize(next); + const current = entry.route.peek(); + update( + named.instance === undefined + ? { ...named, instance: current.instance, epoch: current.epoch } + : named, + ); + insert(); + }, + }); } /** @@ -1698,16 +1824,22 @@ export class Producer implements Table { if (this.#state.closed.peek() !== undefined) return; this.#state.closed.set(abort ?? null); for (const watch of this.#state.watchers.values()) watch.version.update((n) => n + 1); - for (const created of this.#state.created?.values() ?? []) retire(created); + // Cleared before the broadcasts close, so each route's synchronous retraction finds + // nothing left to rebuild rather than rebuilding the table once per route. + const created = this.#state.created; this.#state.created = undefined; this.#state.local.update(() => undefined); this.#state.advertisedLocal.update(() => undefined); + for (const entry of created?.values() ?? []) retire(entry); this.#state.routes.update((routes) => { for (const entries of routes?.values() ?? []) { for (const entry of entries) entry.server?.close(abort); } return undefined; }); + // A hidden or withdrawn prefix route is out of the table but its handler still waits. + for (const server of this.#state.servers) server.close(abort); + this.#state.servers.clear(); this.#state.originated.update(() => undefined); // Materialized broadcasts are handles we opened; release them. for (const cached of this.#state.materialized.values()) { @@ -1752,20 +1884,12 @@ let makeFollowing: ( dispose: Dispose, ) => Following; -let makeDynamic: ( - prefix: Path.Valid, - entry: RouteEntry, - state: OriginState, - retract: Dispose, - publisher?: Producer, -) => Requests; - let makeRequest: (path: Path.Valid, server: ServeState) => Request; let finishRequest: (request: Request, err: Error) => void; let setRequestPublisher: (request: Request, publisher: Producer | undefined) => void; -let makePrepared: (request: Request, publication: Publication) => Prepared; -let publishPrepared: (request: Request, publication: Publication) => Publication; -let rejectPrepared: (request: Request, err: Error) => void; +let jobPending: (request: Request) => boolean; +let answerJob: (request: Request) => void; +let rejectJob: (request: Request, err: Error) => void; /** * An owned request for one publisher instance at a broadcast path; see {@link Consumer.request}. @@ -1902,6 +2026,16 @@ export class Consumer implements Reader { static { makeConsumer = (state, scope) => new Consumer(state, scope); + hooks.withholdHidden = (origin) => { + const withheld = origin.#state.withheld; + withheld.update((count) => count + 1); + let held = true; + return () => { + if (!held) return; + held = false; + withheld.update((count) => count - 1); + }; + }; } /** Settles once the origin closes; see {@link Producer.closed}. */ @@ -2393,21 +2527,6 @@ export class Consumer implements Reader { producer.close(); } - /** - * The local table, borrowed by the wire publishers to answer subscribes. - * - * Deliberately excludes received routes: a session never re-announces what a peer - * told it, so an origin wired to both directions of a connection cannot echo. - * Borrowed, not owned: do not close the fronts. Undefined once the origin closes. - * - * @internal - */ - /** - * Originated advertisements a session should forward: exact-path announces plus - * originated dynamics. Undefined once the origin closes. - * - * @internal - */ /** * The announced local broadcast at `path`, when it beats the originated routes there. * Resolves through what rebuildOriginated advertised: a peer never sees received routes. @@ -2510,140 +2629,172 @@ export class Consumer implements Reader { } } +/** What one {@link Route} does, exact or prefix. */ +interface RouteControl { + readonly broadcast?: broadcast.Producer; + instance(): Instance.Valid; + announce(): void; + withdraw(): void; + announced(): boolean; + change(update: Partial>): void; + requested(): AsyncIterableIterator; + close(): void; + info(): RouteInfo | undefined; + update(info: Partial): void; +} + +/** The requests arriving on a prefix route's `server`, each answerable with a route of `publisher`'s when it has one. */ +async function* serveRequests(server: ServeState, publisher: Producer | undefined): AsyncIterableIterator { + let current: Request | undefined; + const drop = () => { + current?.reject(unroutable()); + current = undefined; + }; + // Keep the cursor while draining: restarting it would rescan deleted entries. + let queued = server.queue.peek().values(); + try { + for (;;) { + const next = queued.next(); + if (!next.done) { + drop(); + server.queue.mutate((queue) => { + queue.delete(next.value); + }); + current = next.value; + setRequestPublisher(current, publisher); + yield next.value; + continue; + } + if (server.closed.peek() !== undefined) return; + await Signal.race(server.queue, server.closed); + queued = server.queue.peek().values(); + } + } finally { + drop(); + } +} + /** - * A served route from {@link Producer.requests}: advertises a prefix and answers the + * A route in an origin: an exact one owning its broadcast, or a prefix one answering the * requests beneath it. * - * Drop it (or {@link close}) to retract the route and reject anything still waiting - * with {@link StreamCode | StreamCode.Unroutable}. Close it and claim again to change it. + * Create an exact route with {@link Producer.route}, a prefix route with + * {@link Producer.routePrefix}, or a job's exact route with {@link Request.route}. + * + * ```ts + * // Fill the catalog before readers can find it. + * const route = origin.route(Path.from("live/alice"), { hidden: true }); + * route.broadcast.createTrack("catalog.json"); + * route.announce(); // visible now, same instance + * route.setCost(5n); // re-prices in place + * route.withdraw(); // hidden; announce() again continues the same stream + * ``` + * + * {@link close} retracts it and closes its broadcast, and so does closing the broadcast. * * @public */ -export class Requests { - /** The prefix this handle advertises. */ - readonly prefix: Path.Valid; - - #entry: RouteEntry; - #state: OriginState; - #retract: Dispose; - #publisher?: Producer; - #closed = false; +export class Route { + readonly #control: RouteControl; - private constructor( - prefix: Path.Valid, - entry: RouteEntry, - state: OriginState, - retract: Dispose, - publisher?: Producer, - ) { - this.prefix = Path.stripPrefix(entry.scope.root, prefix) ?? Path.empty(); - this.#entry = entry; - this.#state = state; - this.#retract = retract; - this.#publisher = publisher; + private constructor(control: RouteControl) { + this.#control = control; + registerWire(this, { + get info() { + return control.info(); + }, + update: (info) => control.update(info), + }); } static { - makeDynamic = (prefix, entry, state, retract, publisher) => - new Requests(prefix, entry, state, retract, publisher); + makeRoute = (control) => new Route(control); + } + + /** The broadcast this exact route owns, to fill with tracks. Throws on a prefix route, which owns none. */ + get broadcast(): broadcast.Producer { + const producer = this.#control.broadcast; + if (!producer) throw new Error("a prefix route owns no broadcast; answer its requests"); + return producer; } - /** The route this handle advertises. */ - get route(): Route { - return this.#entry.route.peek(); + /** The stream this route serves, minted when it was created unless {@link RouteOptions.instance} named one. */ + get instance(): Instance.Valid { + return this.#control.instance(); } /** - * Replace the route in place. The prefix is fixed at announce time. - * - * A route naming no instance keeps the one this handle serves. At the same instance this - * re-prices (or, at a higher epoch, continues the stream): consumers see an update and every - * handle survives. Another instance names another stream: - * consumers see a restart, resolved requests keep their old broadcast until closed, and a - * re-request never joins it. Requests still waiting on this handle carry over: the handler - * is asked again under the new instance, and its answer to a request asked before the change - * is dropped, never served under the new instance. A new or unresolved request pinned to the - * old instance is refused as unroutable. The answers already served are forgotten, so the next - * request for one of those paths asks the handler again too. The broadcasts it served keep - * running for the subscriptions already on them; close them to end those too. - * Route selection still applies across instances. To re-price, start from the current route, `updateRoute({ ...dynamic.route, cost })`. + * Make the route visible, or keep it so. Idempotent. * - * @internal + * Visible to local consumers before it returns. A job's route ({@link Request.route}) also + * answers the requesters waiting on its path. Throws once the route or its broadcast closed, + * and, announcing nothing, with {@link StreamCode | StreamCode.Unroutable} when a job's request + * was released or its prefix route retracted meanwhile: that route can never announce. */ - updateRoute( - route: - | Route - | { - instance?: Route["instance"]; - epoch?: Route["epoch"]; - hops?: Route["hops"]; - cost?: Route["cost"]; - auth?: Route["auth"]; - }, - ): void { - if (this.#closed) throw new Error("dynamic is closed"); - const current = this.#entry.route.peek(); - const named = Route.normalize(route); - // A route naming no instance keeps the one this handle serves. - const next = - named.instance === undefined ? { ...named, instance: current.instance, epoch: current.epoch } : named; - const previous = current.instance; - if (previous !== next.instance) this.#state.restart(this.#entry); - this.#entry.route.set(next); - if (previous !== next.instance) this.#entry.server?.reset(); - this.#state.rebuildOriginated(); - this.#state.refreshPrefix(Path.join(this.#entry.scope.root, this.prefix)); - this.#state.routes.mutate(() => {}); + announce(): void { + this.#control.announce(); + } + + /** + * Hide the route from everyone, local and remote alike, keeping its broadcast and instance: + * {@link announce} brings the same stream back. Requests for the path fail with + * {@link StreamCode | StreamCode.Unroutable} meanwhile, while tracks already in flight carry + * on to their own end. Idempotent. + */ + withdraw(): void { + this.#control.withdraw(); } - /** Retract the route and reject anything still waiting. Idempotent. */ + /** Whether the route is visible. */ + get announced(): boolean { + return this.#control.announced(); + } + + /** + * Re-price the route: lower wins among routes covering the same prefix and anonymity. A + * visible route changes in place, as an update to its readers; a hidden one announces at + * this price. Throws once the route or its broadcast closed. + */ + setCost(cost: Cost): void { + this.#control.change({ cost }); + } + + /** + * Swap the credential the advertising request carries; see {@link RouteOptions.auth}. + * Undefined advertises under the session's scope. Throws once the route or its broadcast closed. + */ + setAuth(auth?: Credential): void { + this.#control.change({ auth }); + } + + /** + * The requests beneath this prefix route, as they arrive, each to answer with + * {@link Request.serve}, {@link Request.route} or {@link Request.reject}. Advancing the + * iterator or closing it without answering rejects the request. Ends once the route is + * closed or the origin closes, and at once on an exact route, which yields no requests. + */ + requested(): AsyncIterableIterator { + return this.#control.requested(); + } + + /** Retract the route for good, closing its broadcast and rejecting anything still waiting. Idempotent. */ close(): void { - if (this.#closed) return; - this.#closed = true; - this.#retract(); - } - - /** Requests under this prefix, as they arrive, each to {@link Request.prepare} or reject. */ - async *requested(): AsyncIterableIterator { - const server = this.#entry.server; - if (!server) return; - let current: Request | undefined; - const drop = () => { - current?.reject(unroutable()); - current = undefined; - }; - // Keep the cursor while draining: restarting it would rescan deleted entries. - let queued = server.queue.peek().values(); - try { - for (;;) { - const next = queued.next(); - if (!next.done) { - drop(); - server.queue.mutate((queue) => { - queue.delete(next.value); - }); - current = next.value; - setRequestPublisher(current, this.#publisher); - yield next.value; - continue; - } - if (server.closed.peek() !== undefined) return; - await Signal.race(server.queue, server.closed); - queued = server.queue.peek().values(); - } - } finally { - drop(); - } + this.#control.close(); + } + + /** Calls {@link close}, so `using` retracts the route. */ + [Symbol.dispose](): void { + this.close(); } } /** * A pending request for a broadcast to be served on demand. * - * Yielded by {@link Requests.requested}. {@link prepare} then {@link Prepared.publish} - * resolves it with a new exact-path broadcast; {@link serve} answers with a held broadcast; - * {@link reject} resolves it with an error. - * Advancing the iterator or closing it without answering rejects the request. + * Yielded by {@link Route.requested}. Answer it one of three ways: {@link serve} a broadcast + * already held, under the prefix route's instance; create the path's own exact route with + * {@link route}, under a fresh instance; or {@link reject} it with an error. Advancing the + * iterator or closing it without answering rejects the request. * * @public */ @@ -2666,16 +2817,14 @@ export class Request { setRequestPublisher = (request, publisher) => { request.#publisher = publisher; }; - publishPrepared = (request, publication) => { - if (!request.#server.has(request)) throw new Error("request is closed"); - publication.announce(); + jobPending = (request) => request.#server.has(request); + answerJob = (request) => { // Its exact route owns availability: never cache a published job under the // prefix, which would keep serving it after that exact route is withdrawn. request.#server.forget(Path.join(request.#server.root, request.path), request); request.#server.settled.update((n) => n + 1); - return publication; }; - rejectPrepared = (request, err) => request.#server.reject(request, err); + rejectJob = (request, err) => request.#server.reject(request, err); finishRequest = (request, err) => { request.#done = true; request.#withdrawn = true; @@ -2683,15 +2832,26 @@ export class Request { }; } - /** Start answering as a new stream under a fresh instance, hidden until published. */ - prepare(): Prepared { + /** + * Answer with the path's own exact route, owning a new broadcast. + * + * The route serves a fresh instance unless `options` names one, so a job that ends and + * restarts beneath the same prefix is a new stream for its restarted sequences. Closing the + * route withdraws only this path: the prefix route keeps answering later requests. Created + * visible, the route announces now and answers the requesters waiting on its path. Created + * {@link RouteOptions.hidden | hidden}, they wait until {@link Route.announce}, so the job can + * fill its catalog first; closing the route or its broadcast before then refuses them. + * + * Throws {@link StreamCode | StreamCode.Unroutable} on a request a session forwarded, which can + * only {@link serve} its peer's broadcast, and, announcing nothing, once the request was + * released or its prefix route retracted. Otherwise it throws as {@link Producer.route} does. + */ + route(options: RouteOptions = {}): Route { if (this.#done) throw new Error("request is closed"); - if (!this.#publisher) throw new Error("forwarded requests cannot be prepared"); - const publication = this.#publisher.createBroadcast(this.path); - // Only the fill handle escapes until publish, so the answer cannot announce itself - // under another instance or skip the request's admission. + if (!this.#publisher) throw unroutable(); + const route = exactRoute(this.#publisher, this.path, options, this); this.#done = true; - return makePrepared(this, publication); + return route; } /** @@ -2699,8 +2859,8 @@ export class Request { * * Takes ownership of a consumer; a producer stays the caller's. Every answer at this * path belongs to the prefix route's instance and must continue the same sequences and - * bytes. Start a new prefix route for different content, or use {@link prepare} and - * {@link Prepared.publish} for a job that can restart independently. + * bytes. Start a new prefix route for different content, or use {@link route} for a job + * that can restart independently. */ serve(source: broadcast.Producer | broadcast.Consumer): void { if (this.#done) { @@ -2721,107 +2881,3 @@ export class Request { this.#server.reject(this, err); } } - -let makePublication: (producer: broadcast.Producer, announcer: broadcast.Announcer) => Publication; - -/** An origin-owned broadcast and the capability to announce its route. */ -export class Publication { - /** The broadcast to fill with tracks before announcing it. */ - readonly broadcast: broadcast.Producer; - readonly #announcer: broadcast.Announcer; - - private constructor(producer: broadcast.Producer, announcer: broadcast.Announcer) { - this.broadcast = producer; - this.#announcer = announcer; - } - - static { - makePublication = (producer, announcer) => new Publication(producer, announcer); - } - - /** The route this broadcast is announced with, if any. */ - get route(): Route | undefined { - return this.#announcer.route(); - } - - /** - * Announce this broadcast, or replace its route; retain its instance when changing only cost. - * Announce once its initial tracks exist. Throws after the broadcast closes or is replaced. - */ - announce( - route: - | Route - | { - instance?: Route["instance"]; - epoch?: Route["epoch"]; - hops?: Route["hops"]; - cost?: Route["cost"]; - auth?: Route["auth"]; - } = Route.default, - ): void { - if (this.broadcast.closed.peek() !== undefined) throw new Error("broadcast is closed"); - this.#announcer.announce(Route.normalize(route)); - } - - /** Retract this broadcast's route until announced again, without closing its fill handle. */ - unannounce(): void { - this.#announcer.unannounce(); - } -} - -/** - * A claim answer being filled before its exact route is announced. - * - * Use `using` or {@link close} to refuse it unless {@link publish} transfers its broadcast. - * Closing the unpublished broadcast also refuses the request. A retained, unclosed answer - * keeps the request pending; garbage collection provides no prompt cleanup guarantee. - */ -export class Prepared { - /** The unannounced broadcast to fill before publishing; only {@link publish} announces it. */ - readonly broadcast: broadcast.Producer; - readonly #request: Request; - readonly #publication: Publication; - readonly #stop: Dispose; - #done = false; - - private constructor(request: Request, publication: Publication) { - this.#request = request; - this.broadcast = publication.broadcast; - this.#publication = publication; - // Synchronous, so a request made right after the broadcast closes reaches the claim again. - this.#stop = hooks.onClosed(this.broadcast, () => this.close()); - } - - static { - makePrepared = (request, publication) => new Prepared(request, publication); - } - - /** Announce the filled broadcast and resolve every waiting requester, refusing them if it cannot be announced. */ - publish(): Publication { - // Closing the broadcast closes this answer at once, so `#done` covers it too. - if (this.#done) throw new Error("prepared answer is closed"); - this.#done = true; - this.#stop(); - try { - return publishPrepared(this.#request, this.#publication); - } catch (err) { - this.broadcast.close(); - rejectPrepared(this.#request, err instanceof Error ? err : new Error(String(err))); - throw err; - } - } - - /** Refuse an unpublished answer as unroutable and close its broadcast. Idempotent. */ - close(): void { - if (this.#done) return; - this.#done = true; - this.#stop(); - this.broadcast.close(); - rejectPrepared(this.#request, unroutable()); - } - - /** Calls {@link close}, so `using` refuses an unpublished answer. */ - [Symbol.dispose](): void { - this.close(); - } -} diff --git a/js/net/src/parked-expiry.test.ts b/js/net/src/parked-expiry.test.ts index 4b302a86f30..624901a6005 100644 --- a/js/net/src/parked-expiry.test.ts +++ b/js/net/src/parked-expiry.test.ts @@ -30,9 +30,8 @@ for (const protocol of protocols) { const installed = clock.install(); const pair = createMockTransportPair(protocol.alpn); const origin = new Origin(); - const broadcastPublication = origin.createBroadcast(Path.from("parked")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("parked")); + const broadcast = broadcastRoute.broadcast; // No publisher limit, so the cache eviction rather than a stale verdict ends a long budget. const track = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); const write = (sequence: bigint) => { @@ -152,9 +151,8 @@ for (const protocol of protocols) { const installed = clock.install(); const pair = createMockTransportPair(protocol.alpn); const origin = new Origin(); - const broadcastPublication = origin.createBroadcast(Path.from("parked")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("parked")); + const broadcast = broadcastRoute.broadcast; const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const write = (sequence: bigint, frames: number) => { const group = track.createGroup(sequence); diff --git a/js/net/src/request-deadline.test.ts b/js/net/src/request-deadline.test.ts index 6052ddb6cd4..a377e4d3948 100644 --- a/js/net/src/request-deadline.test.ts +++ b/js/net/src/request-deadline.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; -import { SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; +import { SessionCode, SessionError } from "./error.ts"; import { HopSchema } from "./hop.ts"; import { NativeSession } from "./ietf/session.ts"; import { Subscriber as IetfSubscriber } from "./ietf/subscriber.ts"; @@ -14,6 +14,7 @@ function limited(mode: "queue" | "open") { const slot = Promise.withResolvers(); const created = Promise.withResolvers(); const aborted = Promise.withResolvers(); + const writing = Promise.withResolvers(); const written: Uint8Array[] = []; const options: unknown[] = []; const stream = () => ({ @@ -22,6 +23,7 @@ function limited(mode: "queue" | "open") { abort: aborted.resolve, write: (bytes) => { written.push(new Uint8Array(bytes)); + writing.resolve(); }, }), }); @@ -37,17 +39,20 @@ function limited(mode: "queue" | "open") { quic, created: created.promise, aborted: aborted.promise, + writing: writing.promise, written, options, release: () => slot.resolve(), }; } -// Waiting for stream credit is waiting its turn, so it never fails a request: the deadline -// runs only once the request's streams are open, for an answer that never comes. +const HOUR = Nano.fromMilli(Milli(3_600_000)); + +// A publisher may hold its answer, so a request waits until its caller leaves, as in Rust. +// Cancellation resets opened streams and gives up queued opens without spending their credit. for (const protocol of ["lite", "ietf"] as const) { for (const mode of ["queue", "open"] as const) { - test(`${protocol}: ${mode} has one fatal request deadline from its open`, async () => { + test(`${protocol}: ${mode} subscribe waits until its subscriber leaves`, async () => { const clock = new Manual(); const installed = clock.install(); const transport = limited(mode); @@ -60,26 +65,19 @@ for (const protocol of ["lite", "ietf"] as const) { try { await transport.created; await clock.advance(Nano.zero); + await clock.advance(HOUR); + expect(track.closed.peek()).toBeUndefined(); + track.close(); if (mode === "queue") { - await clock.advance(Nano.fromMilli(Milli(30_000))); - expect(track.closed.peek()).toBeUndefined(); - expect(transport.written).toEqual([]); transport.release(); await clock.advance(Nano.zero); - expect(transport.written).not.toEqual([]); + expect(transport.written).toEqual([]); + } else { + await transport.aborted; } - await clock.advance(Nano.fromMilli(Milli(9_999))); - expect(track.closed.peek()).toBeUndefined(); - await clock.advance(Nano.fromMilli(Milli(1))); - const failure = await track.closed; - expect(failure).toBeInstanceOf(StreamError); - expect((failure as StreamError).code).toBe(StreamCode.ControlTimeout); - await transport.aborted; - // A lite subscription opens its SUBSCRIBE alongside its TRACK, under the one deadline. + // A lite subscription opens its SUBSCRIBE alongside its TRACK. const opened = protocol === "lite" ? 2 : 1; expect(transport.options).toEqual(Array.from({ length: opened }, () => ({ waitUntilAvailable: true }))); - await clock.advance(Nano.fromMilli(Milli(30_000))); - expect(transport.options).toHaveLength(opened); } finally { track.close(); broadcast.close(); @@ -90,38 +88,79 @@ for (const protocol of ["lite", "ietf"] as const) { } } +for (const protocol of ["lite", "ietf"] as const) { + test(`${protocol}: an unanswered track info waits until its caller leaves`, async () => { + const clock = new Manual(); + const installed = clock.install(); + const transport = limited("open"); + const subscriber = + protocol === "lite" + ? new LiteSubscriber(transport.quic, Lite.DRAFT_07, HopSchema.parse(1n)) + : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_18, true) }); + const broadcast = subscriber.consume(Path.from("room")); + const cancel = new AbortController(); + let failure: unknown; + const info = broadcast + .track("video") + .info({ signal: cancel.signal }) + .catch((err: unknown) => { + failure = err; + }); + try { + await transport.created; + await clock.advance(HOUR); + expect(failure).toBeUndefined(); + const left = new Error("left"); + cancel.abort(left); + await info; + expect(failure).toBe(left); + await transport.aborted; + } finally { + broadcast.close(); + if (subscriber instanceof LiteSubscriber) subscriber.close(); + installed.close(); + } + }); +} + for (const operation of ["info", "fetch"] as const) { for (const mode of ["queue", "open"] as const) { - test(`lite standalone ${operation}: ${mode} has a fatal deadline from its open`, async () => { + test(`lite standalone ${operation}: ${mode} waits until its caller leaves`, async () => { const clock = new Manual(); const installed = clock.install(); const transport = limited(mode); const subscriber = new LiteSubscriber(transport.quic, Lite.DRAFT_07, HopSchema.parse(1n)); + const cancel = new AbortController(); let failure: unknown; const request = ( operation === "info" - ? subscriber.resolveTrackInfo(Path.from("room"), "video") - : subscriber.fetchGroup(Path.from("room"), "video", 0n) + ? subscriber.resolveTrackInfo(Path.from("room"), "video", undefined, cancel.signal) + : subscriber.fetchGroup(Path.from("room"), "video", 0n, { signal: cancel.signal }) ).catch((err: unknown) => { failure = err; }); try { await transport.created; await clock.advance(Nano.zero); + await clock.advance(HOUR); + expect(failure).toBeUndefined(); + const left = new Error("left"); + cancel.abort(left); + await request; + expect(failure).toBe(left); + // Credit granted once the caller has left can't send the request. + await clock.advance(Nano.zero); if (mode === "queue") { - await clock.advance(Nano.fromMilli(Milli(30_000))); - expect(failure).toBeUndefined(); transport.release(); await clock.advance(Nano.zero); + expect(transport.written).toEqual([]); + } else { + await transport.aborted; } - await clock.advance(Nano.fromMilli(Milli(10_000))); - await request; - expect((failure as StreamError).code).toBe(StreamCode.ControlTimeout); - // A fetch opens its FETCH stream alongside the TRACK stream, under the same deadline. - expect(transport.options).toHaveLength(operation === "info" ? 1 : 2); - // The FETCH stream opens at its group's send order. - expect(transport.options[0]).toEqual({ waitUntilAvailable: true }); - for (const open of transport.options) expect(open).toMatchObject({ waitUntilAvailable: true }); + // A fetch opens its FETCH stream alongside the TRACK stream. + const opened = transport.options as { sendOrder?: number; waitUntilAvailable: boolean }[]; + expect(opened).toHaveLength(operation === "info" ? 1 : 2); + for (const open of opened) expect(open.waitUntilAvailable).toBe(true); } finally { subscriber.close(); installed.close(); @@ -162,6 +201,35 @@ for (const protocol of ["lite", "ietf"] as const) { } } +for (const protocol of ["lite", "ietf"] as const) { + test(`${protocol}: credit granted as the subscriber leaves cannot send a late request`, async () => { + const clock = new Manual(); + const installed = clock.install(); + const transport = limited("queue"); + const subscriber = + protocol === "lite" + ? new LiteSubscriber(transport.quic, Lite.DRAFT_07, HopSchema.parse(1n)) + : new IetfSubscriber({ session: new NativeSession(transport.quic, Ietf.DRAFT_18, true) }); + const broadcast = subscriber.consume(Path.from("room")); + const track = broadcast.track("video").subscribe(); + try { + await transport.created; + track.close(); + transport.release(); + await clock.advance(Nano.zero); + expect(transport.written).toEqual([]); + const next = broadcast.track("next").subscribe(); + await transport.writing; + next.close(); + } finally { + track.close(); + broadcast.close(); + if (subscriber instanceof LiteSubscriber) subscriber.close(); + installed.close(); + } + }); +} + for (const protocol of ["lite", "ietf"] as const) { test(`${protocol}: a create the closed session fails reports the session`, async () => { const quic = { diff --git a/js/net/src/resume.model.test.ts b/js/net/src/resume.model.test.ts index 919dee85a19..cf06c9f77fc 100644 --- a/js/net/src/resume.model.test.ts +++ b/js/net/src/resume.model.test.ts @@ -17,7 +17,7 @@ import fc from "fast-check"; import { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import { NotFound, StreamCode, StreamError, TooFarBehind } from "./error.ts"; import type { Frame, Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; -import { HopSchema, Route } from "./hop.ts"; +import { HopSchema, RouteInfo } from "./hop.ts"; import * as Instance from "./instance.ts"; import { hooks } from "./internal.ts"; import { type Consumer as OriginConsumer, Producer as OriginProducer, type Requesting } from "./origin.ts"; @@ -447,7 +447,7 @@ class Model { up(instance: number, slot: number): void { const publisher = this.instances[instance]; if (publisher.routes[slot]) return; - const route = Route.normalize({ + const route = RouteInfo.normalize({ instance: INSTANCES[instance], hops: [HopSchema.parse(BigInt(10 * (instance + 1) + slot))], // The second instance's routes all undercut the first's. diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 34dbb1c308a..1d2eeaf38a4 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -3,7 +3,7 @@ import { Manual, Milli, Nano, yieldNow } from "@moq/clock"; import { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import { NotFound, SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; import { type Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; -import { Route } from "./hop.ts"; +import { RouteInfo } from "./hop.ts"; import type { Session as IetfSession } from "./ietf/session.ts"; import { Subscriber as IetfSubscriber } from "./ietf/subscriber.ts"; import { Version as IetfVersion } from "./ietf/version.ts"; @@ -39,7 +39,7 @@ async function isPending(promise: Promise): Promise { /** A stand-in session announcing `path` under the shared instance at `cost`, serving `source`. */ function serve(origin: Producer, source: BroadcastProducer, cost: bigint) { - const handle = wireOf(origin).receive(path, Route.normalize({ instance: INSTANCE, cost })); + const handle = wireOf(origin).receive(path, RouteInfo.normalize({ instance: INSTANCE, cost })); void (async () => { for await (const request of handle.requested()) request.serve(source.consume()); })(); @@ -485,9 +485,8 @@ test("a resumed fork shares an on-demand track request", async () => { test("a lagging same-instance replica resumes from one publisher's bytes exactly once", async () => { const { clock, installed } = manual(); const publisher = new Producer(); - const originalPublication = publisher.createBroadcast(path); - const original = originalPublication.broadcast; - originalPublication.announce({ instance: INSTANCE }); + const originalRoute = publisher.route(path, { instance: INSTANCE }); + const original = originalRoute.broadcast; const source = original.createTrack("seq", { maxAge: null }); const origin = new Producer(); @@ -758,6 +757,257 @@ test("a replacement differing only in unchecked takes over", async () => { close(); }); +// Mirrors `a_replacement_aborted_after_its_answer_keeps_the_incumbent` in rs/moq-net/src/model/origin.rs. +test("a replacement that answers and then aborts before its first group leaves the incumbent serving", async () => { + const { a, b, swap, active, close } = await setup(); + const ta = copy(a); + const tb = copy(b); + const sub = active.track("seq").subscribe(); + ta.write(0n); + expect(drain(sub)).toEqual([0n]); + + await swap(); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + expect(sub.closed.peek()).toBeUndefined(); + + ta.write(1n); + await settle(); + expect(drain(sub)).toEqual([1n]); + + // The incumbent's clean end is the track's, not the replacement's error. + ta.producer.finish(); + expect(await sub.recvGroup()).toBeUndefined(); + + sub.close(); + close(); +}); + +// Mirrors the Rust front: an incumbent that finished cleanly ends the track whoever serves it. +test("a replacement that aborts before its first group after the incumbent finished ends the track cleanly", async () => { + const { a, b, swap, active, close } = await setup(); + const ta = copy(a); + const tb = copy(b); + const sub = active.track("seq").subscribe(); + ta.write(0n); + expect(drain(sub)).toEqual([0n]); + + await swap(); + ta.write(1n); + ta.producer.finish(); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + const groups: bigint[] = []; + for (let group = await sub.recvGroup(); group; group = await sub.recvGroup()) { + groups.push(group.sequence); + group.close(); + } + expect(groups).toEqual([1n]); + + sub.close(); + close(); +}); + +// Mirrors `readers_owed_nothing_below_the_incumbents_end_end_cleanly` in rs/moq-net/src/model/origin.rs: +// a reader owed nothing below the incumbent's end, and one joining while the replacement has yet +// to deliver, end cleanly once the incumbent finished and the replacement aborted. +test("readers owed nothing below the incumbent's end end cleanly when the replacement aborts", async () => { + const { a, b, swap, active, close } = await setup(); + const ta = copy(a); + const tb = copy(b); + const later = active.track("seq").subscribe({ ranges: [{ start: { absolute: 3n } }] }); + ta.write(0n); + ta.write(1n); + await swap(); + const joiner = active.track("seq").subscribe(); + await settle(); + ta.producer.finish(); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + + expect(await later.recvGroup()).toBeUndefined(); + const groups: bigint[] = []; + for (let group = await joiner.recvGroup(); group; group = await joiner.recvGroup()) { + groups.push(group.sequence); + group.close(); + } + expect(groups).toEqual([1n]); + + later.close(); + joiner.close(); + close(); +}); + +// A reader arriving after the replacement answered reads the incumbent too, as the Rust front +// serves it until the replacement delivers. +test("a reader joining before the replacement delivers keeps the incumbent when the replacement aborts", async () => { + const { a, b, swap, active, close } = await setup(); + const ta = copy(a); + const tb = copy(b); + const first = active.track("seq").subscribe(); + ta.write(0n); + expect(drain(first)).toEqual([0n]); + await swap(); + const joiner = active.track("seq").subscribe(); + await settle(); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + ta.write(1n); + await settle(); + expect(drain(joiner)).toContain(1n); + expect(joiner.closed.peek()).toBeUndefined(); + + first.close(); + joiner.close(); + close(); +}); + +// The incumbent is the source that last delivered, however many replacements answered since. +test("a reader joining after two undelivered replacements keeps the incumbent when both abort", async () => { + const { origin, a, b, swap, active, close } = await setup(); + const c = new BroadcastProducer(); + const ta = copy(a); + const tb = copy(b); + const tc = copy(c); + const first = active.track("seq").subscribe(); + ta.write(0n); + expect(drain(first)).toEqual([0n]); + await swap(); + const disposeC = serve(origin, c, 0n); + await settle(); + await settle(); + const joiner = active.track("seq").subscribe(); + await settle(); + tb.producer.abort(new StreamError(StreamCode(64))); + tc.producer.abort(new StreamError(StreamCode(64))); + await settle(); + ta.write(1n); + await settle(); + expect(drain(joiner)).toContain(1n); + expect(joiner.closed.peek()).toBeUndefined(); + + first.close(); + joiner.close(); + disposeC(); + close(); +}); + +// A fork joins like any reader arriving before the replacement delivers. +test("a fork made before the replacement delivers keeps the incumbent when the replacement aborts", async () => { + const { a, b, swap, active, close } = await setup(); + const ta = copy(a); + const tb = copy(b); + const sub = active.track("seq").subscribe(); + ta.write(0n); + expect(drain(sub)).toEqual([0n]); + await swap(); + const fork = sub.fork(); + await settle(); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + ta.write(1n); + await settle(); + expect(drain(fork)).toContain(1n); + expect(fork.closed.peek()).toBeUndefined(); + + sub.close(); + fork.close(); + close(); +}); + +// Only an end some other copy settled cleanly excuses the serving copy's failure. +test("a serving copy that declares an end and then aborts short of it keeps the error", async () => { + const { a, active, close } = await setup(); + const ta = copy(a); + const sub = active.track("seq").subscribe(); + ta.write(0n, 1, false); + ta.producer.finishAt({ group: 1n, frame: 0 }); + const group = await sub.recvGroup(); + group?.close(); + ta.producer.abort(new StreamError(StreamCode(64))); + await settle(); + await expect(sub.recvGroup()).rejects.toThrow("64"); + + sub.close(); + close(); +}); + +// Mirrors `a_replacement_aborted_after_its_answer_keeps_the_incumbent` for a reader awaiting only the end. +test("waiting for the end of a track whose replacement aborts before its first group follows the incumbent", async () => { + const { a, b, swap, active, close } = await setup(); + const ta = copy(a); + const tb = copy(b); + const sub = active.track("seq").subscribe(); + ta.write(0n); + expect(drain(sub)).toEqual([0n]); + + await swap(); + const finished = sub.finished(); + finished.catch(() => {}); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + ta.producer.finish(); + await expect(finished).resolves.toEqual({ group: 1n, frame: 0 }); + + sub.close(); + close(); +}); + +// The Rust front judges the replacement by its copy, whatever each reader's window took from it: +// one that held a group delivered, so its later abort is the track's verdict for every reader. +test("a replacement that delivered is not undone by a reader whose window skipped its groups", async () => { + const { a, b, swap, active, close } = await setup(); + const ta = copy(a); + const tb = copy(b); + const first = active.track("seq").subscribe(); + const later = active.track("seq").subscribe({ ranges: [{ start: { absolute: 3n } }] }); + ta.write(0n); + expect(drain(first)).toEqual([0n]); + expect(drain(later)).toEqual([]); + + await swap(); + tb.write(1n); + await settle(); + expect(drain(first)).toEqual([1n]); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + expect(() => drain(later)).toThrow("64"); + ta.write(2n); + await settle(); + expect(() => drain(first)).toThrow("64"); + + first.close(); + later.close(); + close(); +}); + +test("a replacement that aborts before its first group after the front concludes leaves the incumbent serving", async () => { + const front = new Front(); + const a = new BroadcastProducer(); + const b = new BroadcastProducer(); + const ta = copy(a); + const tb = copy(b); + front.serve(a.consume()); + const sub = front.consumer.track("seq").subscribe(); + ta.write(0n); + expect(drain(sub)).toEqual([0n]); + + front.serve(b.consume()); + await settle(); + // No front is left to replace the replacement, but the incumbent still feeds the track. + front.conclude(); + tb.producer.abort(new StreamError(StreamCode(64))); + await settle(); + expect(sub.closed.peek()).toBeUndefined(); + + ta.write(1n); + await settle(); + expect(drain(sub)).toEqual([1n]); + ta.producer.finish(); + expect(await sub.recvGroup()).toBeUndefined(); + sub.close(); +}); + test("waiting for the end of a track whose replacement was refused ends with the refusal", async () => { const { a, b, swap, active, close } = await setup(); const old = a.createTrack("seq", { priority: 1 }); @@ -943,7 +1193,7 @@ test("between routes a reader keeps reading the copy it has", async () => { expect(drain(sub)).toEqual([0n]); // The next route has not answered when the old one is retracted. - const next = wireOf(origin).receive(path, Route.normalize({ instance: INSTANCE, cost: 1n })); + const next = wireOf(origin).receive(path, RouteInfo.normalize({ instance: INSTANCE, cost: 1n })); const asked = next.requested(); disposeA(); await settle(); @@ -1832,7 +2082,7 @@ test("a fetched group that fails on the route still serving waits for the next r const b = new BroadcastProducer(); const ta = copy(a); const tb = copy(b); - // Route a's request handler holds the refetch of its failed group open, as Rust's tests do + // RouteInfo a's request handler holds the refetch of its failed group open, as Rust's tests do // with `requests()`: without one, a miss there is NotFound at once. const pending = ta.producer.requests(); const failing = ta.write(5n, 1, false); @@ -1864,7 +2114,7 @@ test("a group handed out already failed with its route still continues on the ne const b = new BroadcastProducer(); const ta = copy(a); const tb = copy(b); - // Route a's request handler holds the refetch of its failed group open until the next route. + // RouteInfo a's request handler holds the refetch of its failed group open until the next route. const pending = ta.producer.requests(); front.serve(a.consume()); const sub = front.consumer.track("seq").subscribe(); @@ -3163,3 +3413,33 @@ test("a route whose IETF session closed before subscribing moves the track to th front.consumer.close(); b.close(); }); + +test("routed metadata receives a refusal without polling a group cursor", async () => { + const clock = new Manual(); + const installed = clock.install(); + const origin = new Producer(); + try { + origin.route(Path.from("missing-track")); + for (const request of [origin.request(Path.from("missing-track")), origin.follow(Path.from("missing-track"))]) { + const broadcast = request.active.peek(); + if (!broadcast) throw new Error("local broadcast missing"); + const subscriber = broadcast.track("absent").subscribe(); + let outcome: unknown; + void subscriber.info().then( + () => { + outcome = "accepted"; + }, + (error) => { + outcome = error; + }, + ); + await clock.advance(Nano.zero); + expect(outcome).toBeInstanceOf(NotFound); + subscriber.close(); + request.close(); + } + } finally { + origin.close(); + installed.close(); + } +}); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index c4868c61f1e..a7d3cf908a8 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -203,6 +203,12 @@ class Route { concluded = false; /** The front refused the replacement: the track ends with this once the serving copy runs out. */ refusal?: Error; + /** + * The source serving before the replacements that have yet to deliver, held until a copy of + * `source` delivers: a reader arriving meanwhile reads it too, as the readers before it do, so + * a replacement failing first leaves it serving. The Rust front only swaps once one delivers. + */ + incumbent?: Source; /** Bumped on every change above, waking parked reads. */ readonly changed = new Signal(0); readonly readers = new Set(); @@ -222,16 +228,36 @@ class Route { // A refusal ends the track: no later source continues it, compatible or not. if (this.concluded || this.refusal || this.source === source) return; this.generation++; + // An undelivered source hands over the incumbent it held, not itself. + if (!this.incumbent) this.#hold(this.source); this.source = source; this.served = source; this.#bump(); for (const reader of [...this.readers]) reader.sync(); } + /** + * Serve `to` again in place of `from`, a replacement whose copy failed before delivering, even + * past a refusal or a conclusion: `to` served before it, and concluding only stops a future + * source taking over. Returns whether `to` serves again. + */ + revert(from: Source, to: Source): boolean { + // A concluded route serves nothing new, but the failed replacement was still its last. + if (this.source !== from && !(this.concluded && this.source === undefined)) return false; + this.generation++; + this.#hold(undefined); + this.source = to; + this.served = to; + this.#bump(); + for (const reader of [...this.readers]) reader.sync(); + return true; + } + /** Between routes: readers wait for the next one, keeping what they have. */ park(): void { if (!this.source) return; this.generation++; + this.#hold(undefined); this.source = undefined; this.#bump(); } @@ -257,12 +283,27 @@ class Route { conclude(): void { if (this.concluded) return; this.concluded = true; + this.#hold(undefined); this.source = undefined; this.#bump(); } + /** A copy of `source` delivered: it no longer needs the source it replaced. */ + delivered(source: Source): void { + if (source === this.source) this.#hold(undefined); + } + + #hold(incumbent: Source | undefined): void { + incumbent?.retain(); + this.incumbent?.release(); + this.incumbent = incumbent; + } + leave(reader: Reader): void { - if (this.readers.delete(reader) && this.readers.size === 0) this.idle(); + if (!this.readers.delete(reader) || this.readers.size > 0) return; + // Nobody reads the track, so nothing is left to protect from the replacement. + this.#hold(undefined); + this.idle(); } } @@ -361,6 +402,8 @@ class Reader implements ResumeSource { #delivered = new Delivered(); /** Runs the serving copy gave up and the reader has yet to report; see {@link #tryDrop}. */ #drops: DropRun[] = []; + /** A copy that declared the track's end ran out cleanly at it, even if since let go. */ + #settled = false; /** Whether groups were ever asked for; until then the reader keeps no replaced copy. */ #groupsRead = false; /** The groups handed out and still read, which other copies may back; see {@link #backs}. */ @@ -389,13 +432,15 @@ class Reader implements ResumeSource { // concluded. if (parent) { this.#generation = parent.generation; - this.#add({ + const copy: Copy = { generation: parent.generation, source: parent.source, sub: parent.sub.fork(this.#wire({})), replaced: false, lease: 0, - }); + }; + this.#add(copy); + this.#joinIncumbent(copy); } // Subscribed to the serving copy at once, so groups it has now are the reader's even if // the route changes before the first read, and to each copy after. @@ -450,7 +495,7 @@ class Reader implements ResumeSource { return; } const floor = this.#newest; - this.#add({ + const serving: Copy = { generation: route.generation, source, // The wire request carries the floor from the first message: some sessions read the @@ -459,7 +504,36 @@ class Reader implements ResumeSource { floor, replaced: false, lease: 0, - }); + }; + this.#add(serving); + this.#joinIncumbent(serving); + } + + /** + * Arriving before the serving copy delivered, read the source it replaced too, as the readers + * before this one still do. + */ + #joinIncumbent(serving: Copy): void { + const route = this.route; + const incumbent = route.incumbent; + if (!incumbent || serving.source !== route.source) return; + if (hooks.latestSequence(serving.sub) !== undefined) { + route.delivered(serving.source); + return; + } + if (this.#copies.some((copy) => copy.source.from.closed === incumbent.from.closed)) return; + const floor = serving.floor; + this.#add( + { + generation: serving.generation - 1, + source: incumbent, + sub: subscribe(incumbent.wire, route.name, this.#wire({ floor })), + floor, + replaced: true, + lease: 0, + }, + this.#copies.indexOf(serving), + ); } /** Read `copy` as the newest one, or at `index`, recording the properties it commits. */ @@ -475,7 +549,11 @@ class Reader implements ResumeSource { record(info, source); shell.commit(info); }, - () => {}, + () => { + // A terminal refusal before metadata is the subscription's answer too. An info + // reader never polls the group cursor that would otherwise propagate it. + if (this.#shell.info() === undefined) this.#end((copy) => copy.sub.closed.peek()); + }, ); copy.sub.finished().then( // A copy already let go says nothing more about the track. @@ -654,6 +732,7 @@ class Reader implements ResumeSource { #observe(copy: Copy, sequence: bigint): void { copy.delivered = true; if (copy.replaced || copy.generation !== this.#generation) return; + this.route.delivered(copy.source); const bound = max(this.#newest ?? -1n, sequence - 1n); for (const replaced of this.#copies) { if (replaced === copy || !replaced.replaced || replaced.until !== undefined) continue; @@ -711,8 +790,10 @@ class Reader implements ResumeSource { } // Drained: the track ended as the copy did, which is not cleanly when its // session went away short of the declared end. - if (recv.kind === "done") copy.done = copy.sub.closed.peek() ?? null; - else if (recv.kind === "error") copy.done = recv.error; + if (recv.kind === "done") { + copy.done = copy.sub.closed.peek() ?? null; + if (copy.done === null && hooks.subscriberEnd(copy.sub)) this.#settled = true; + } else if (recv.kind === "error") copy.done = recv.error; break; } } @@ -846,6 +927,23 @@ class Reader implements ResumeSource { // serve it next. Once the track ended, a copy let go held nothing more. if (serving ? ended === null : this.#ended) return this.#conclude() ? { kind: "done" } : { kind: "idle" }; if (ended instanceof Error) { + // A copy it replaced finished the track and the reader owes nothing below that end: + // the track ended cleanly whoever serves it now. Mirrors the Rust front. The failing + // copy's own end, or one whose copy failed short of it, never settled. + const end = this.#learnEnd(); + const owed = max(this.#shell.cursor.peek().start, (this.#newest ?? -1n) + 1n); + const settled = + this.#settled || + this.#copies.some( + (copy) => + copy !== serving && + hooks.subscriberEnd(copy.sub) !== undefined && + hooks.peekLargest(copy.sub).ended && + !(copy.sub.closed.peek() instanceof Error), + ); + if (end && settled && !this.#declaredError && finalSequence(end) <= owed) + return this.#conclude() ? { kind: "done" } : { kind: "idle" }; + if (serving && this.#revert(serving, ended)) return { kind: "idle" }; // A refusal from a route that still serves is the track's answer. if (!routeFailed(ended)) return this.#fail(ended); if (route.refusal) return this.#fail(route.refusal); @@ -872,6 +970,26 @@ class Reader implements ResumeSource { return { kind: "idle" }; } + /** + * A replacement that failed before its copy held a group never ends the track while a copy it + * replaced still feeds it: that copy serves again, and the failure is the replacement refusing + * the track unless its route is going away. Judged by the copy, whatever this reader's window + * took from it, as the Rust front does. + */ + #revert(serving: Copy, failed: Error): boolean { + if (hooks.latestSequence(serving.sub) !== undefined) return false; + const incumbent = this.#copies.findLast( + (copy) => + copy.replaced && + copy.source !== serving.source && + copy.done === undefined && + copy.sub.closed.peek() === undefined, + ); + if (!incumbent || !this.route.revert(serving.source, incumbent.source)) return false; + if (!closing(failed)) this.route.refuse(failed); + return true; + } + #fail(error: Error): Recv & { kind: "error" } { this.#shell.close(error); return { kind: "error", error }; @@ -1213,7 +1331,8 @@ class Reader implements ResumeSource { const failed = serving?.sub.closed.peek(); // A copy that failed is only the end of the track if no front replaces it, and a // refused replacement leaves the refusal as the answer. - if (failed instanceof Error) { + if (serving && failed instanceof Error) { + if (this.#revert(serving, failed)) continue; if (!routeFailed(failed)) throw failed; if (this.route.refusal) throw this.route.refusal; if (this.route.concluded) throw failed; @@ -1231,6 +1350,25 @@ class Reader implements ResumeSource { } } + watchError(fn: (error: Error) => void): Dispose { + let stop: Dispose = () => {}; + const check = () => { + stop(); + stop = () => {}; + this.sync(); + const copy = this.#serving(); + if (copy) stop = hooks.watchSubscriberError(copy.sub, fn); + const changed = this.route.changed.changed(check); + const previous = stop; + stop = () => { + previous(); + changed(); + }; + }; + check(); + return () => stop(); + } + bufferedLargest(): track.Position | undefined { this.sync(); const serving = this.#serving(); diff --git a/js/net/src/retention.test.ts b/js/net/src/retention.test.ts index 029176110d3..81d5d6d4f4b 100644 --- a/js/net/src/retention.test.ts +++ b/js/net/src/retention.test.ts @@ -81,9 +81,8 @@ async function session(alpn: string) { accept({ transport: pair.server, url, publish: origin.consume() }), ]); - const broadcastPublication = origin.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = wireOf(client).consume(Path.from("test")); diff --git a/js/net/src/socket.test.ts b/js/net/src/socket.test.ts new file mode 100644 index 00000000000..968b839aff5 --- /dev/null +++ b/js/net/src/socket.test.ts @@ -0,0 +1,764 @@ +import { afterEach, expect, test } from "bun:test"; +import { Manual, Milli, Nano, Second } from "@moq/clock"; +import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; +import { accept as acceptConnection } from "./connection/accept.ts"; +import { connect } from "./connection/connect.ts"; +import { NotFound, ProtocolViolation, StreamCode, StreamError } from "./error.ts"; +import * as Ietf from "./ietf/index.ts"; +import { Origin, Path, socket } from "./index.ts"; +import { hooks } from "./internal.ts"; +import * as Lite from "./lite/index.ts"; +import { createMockTransportPair } from "./mock.ts"; +import type * as Track from "./track.ts"; +import { wireOf } from "./wire.ts"; + +const origins = new Set(); +const clocks: { close(): void }[] = []; +afterEach(() => { + for (const origin of origins) origin.close(); + origins.clear(); + for (const clock of clocks.reverse()) clock.close(); + clocks.length = 0; +}); +function time() { + const clock = new Manual(); + clocks.push(clock.install()); + return clock; +} +const bytes = (value: number) => ({ payload: new Uint8Array([value]) }); +function setup() { + const origin = new Origin.Producer(); + origins.add(origin); + const a = new socket.Endpoint(origin, Path.from("room"), "alice"); + const b = new socket.Endpoint(origin, Path.from("room"), "bob"); + return { origin, a, b }; +} +async function tcp() { + const state = setup(); + const incoming = state.b.acceptTcp(); + const dialing = state.a.dialTcp("bob"); + const remote = await (await incoming).accept(); + return { ...state, local: await dialing, remote }; +} +async function track() { + const state = setup(); + const incoming = state.b.acceptTrack(); + const dialing = state.a.dialTrack("bob"); + const remote = await (await incoming).accept(); + return { ...state, local: await dialing, remote }; +} +async function broadcast(origin: Origin.Producer, id: string): Promise { + const request = origin.request(Path.from("room/.socket", id)); + const active = request.active.peek(); + if (!active) throw new Error("endpoint not routed locally"); + return active; +} +async function raw(origin: Origin.Producer, id: string, name: string): Promise { + return (await broadcast(origin, id)).track(name).subscribe({ order: "ascending", delay: { max: null } }); +} + +test("socket namespace exposes endpoint and both stream roles", () => { + expect(socket.Endpoint).toBeFunction(); + expect(socket.TcpStream).toBeFunction(); + expect(socket.TrackStream).toBeFunction(); +}); + +test("TCP carries ordered frames in both directions, with independent FIN", async () => { + const { a, b, local, remote } = await tcp(); + expect(local.peer).toBe("bob"); + expect(remote.peer).toBe("alice"); + expect(local.session).toBe(remote.session); + expect(local.session).toMatch(/^[0-9a-f]{32}$/); + for (let i = 0; i < 3; i++) local.producer.writeFrame(bytes(i)); + local.producer.finish(); + for (let i = 0; i < 3; i++) expect((await remote.consumer.readFrame())?.payload).toEqual(bytes(i).payload); + expect(await remote.consumer.readFrame()).toBeUndefined(); + remote.producer.writeFrame(bytes(9)); + remote.producer.finish(); + expect((await local.consumer.readFrame())?.payload).toEqual(bytes(9).payload); + expect(await local.consumer.readFrame()).toBeUndefined(); + a.close(); + b.close(); +}); + +test("a peer route replaced between metadata and opening cannot inherit a socket session", async () => { + const clock = time(); + const { origin, a } = setup(); + const peer = new BroadcastProducer(); + const keep = peer.consume(); + const routes: { close(): void }[] = []; + const tasks: Promise[] = []; + const advertise = (front: BroadcastConsumer) => { + const route = wireOf(origin).receive(Path.from("room/.socket/remote")); + routes.push(route); + tasks.push( + (async () => { + try { + for await (const request of route.requested()) request.serve(front.clone()); + } catch { + /* Closing the route ends its request loop. */ + } + })(), + ); + return route; + }; + const first = advertise(keep); + const requested = wireOf(peer).requested(); + let outcome: unknown; + const dialing = a.dialTrack("remote").then( + (value) => { + outcome = value; + }, + (error) => { + outcome = error; + }, + ); + let returning: Track.Subscriber | undefined; + const replacement = new BroadcastProducer(); + const replacementKeep = replacement.consume(); + try { + const request = await requested; + if (!request) throw new Error("missing dial"); + returning = await raw(origin, "alice", request.name); + await returning.info(); + request.accept({ maxAge: null }); + // Both route changes happen before the info promise's continuation opens the session. + first.close(); + advertise(replacementKeep); + await clock.advance(Nano.zero); + expect(outcome).toBeInstanceOf(Error); + } finally { + a.close(); + for (const route of routes) route.close(); + returning?.close(); + keep.close(); + replacementKeep.close(); + peer.close(); + replacement.close(); + await Promise.all(tasks); + await dialing; + } +}); + +test("track halves start reliable and ascending, preserve old messages and accept independent info", async () => { + const { local, remote } = await track(); + expect((await local.producer.info()).maxAge).toBeUndefined(); + expect(remote.consumer.subscription.peek()?.order).toBe("ascending"); + expect(remote.consumer.subscription.peek()?.delay?.max).toBe(Milli(Number.MAX_SAFE_INTEGER)); + for (let i = 0; i < 3; i++) local.producer.writeFrame(bytes(i)); + local.producer.finish(); + const reader = remote.consumer.ordered(); + for (let i = 0; i < 3; i++) { + const frame = await reader.readFrame(); + expect(frame?.group).toBe(BigInt(i)); + expect(frame?.payload).toEqual(bytes(i).payload); + } + expect(await reader.readFrame()).toBeUndefined(); + remote.producer.writeFrame(bytes(7)); + remote.producer.finish(); + expect((await local.consumer.ordered().readFrame())?.payload).toEqual(bytes(7).payload); +}); + +test("a track session skips dropped groups and keeps the next message", async () => { + const clock = time(); + const { local, remote } = await track(); + const reader = remote.consumer.ordered(); + const reading = reader.readFrame(); + hooks.dropTrackGroups(local.producer, 0n, 1n, new NotFound("message unavailable")); + const group = local.producer.createGroup(1n); + group.writeFrame(bytes(7)); + group.finish(); + local.producer.finish(); + await clock.advance(Nano.zero); + const frame = await reading; + expect(frame?.group).toBe(1n); + expect(frame?.payload).toEqual(bytes(7).payload); + expect(await reader.readFrame()).toBeUndefined(); +}); + +test("each protocol has its own accept queue and sessions never reuse an identity", async () => { + const { a, b } = setup(); + const nextTrack = b.acceptTrack(); + const nextTcp = b.acceptTcp(); + const dialingTcp = a.dialTcp("bob"); + const dialingTrack = a.dialTrack("bob"); + const [incomingTrack, incomingTcp] = await Promise.all([nextTrack, nextTcp]); + expect(incomingTcp.session).not.toBe(incomingTrack.session); + const [remoteTrack, remoteTcp, localTrack, localTcp] = await Promise.all([ + incomingTrack.accept(), + incomingTcp.accept(), + dialingTrack, + dialingTcp, + ]); + localTrack.producer.writeFrame(bytes(1)); + localTcp.producer.writeFrame(bytes(2)); + expect((await remoteTrack.consumer.ordered().readFrame())?.payload).toEqual(bytes(1).payload); + expect((await remoteTcp.consumer.readFrame())?.payload).toEqual(bytes(2).payload); +}); + +test("finished track sessions release their unlisted producers after both halves end", async () => { + const clock = time(); + const { local, remote } = await track(); + const localReader = local.consumer.ordered(); + const remoteReader = remote.consumer.ordered(); + local.producer.writeFrame(bytes(1)); + remote.producer.writeFrame(bytes(2)); + local.producer.finish(); + remote.producer.finish(); + expect((await localReader.readFrame())?.payload).toEqual(bytes(2).payload); + expect((await remoteReader.readFrame())?.payload).toEqual(bytes(1).payload); + expect(await localReader.readFrame()).toBeUndefined(); + expect(await remoteReader.readFrame()).toBeUndefined(); + await clock.advance(Nano.zero); + expect(hooks.trackReleased(local.producer).peek()).toBeNull(); + expect(hooks.trackReleased(remote.producer).peek()).toBeNull(); +}); + +for (const protocol of ["tcp", "track"] as const) { + test(`late ${protocol} reads retain return-routability checks after the original dial closes`, async () => { + const clock = time(); + const { origin, a, b, local, remote } = await (protocol === "tcp" ? tcp() : track()); + const next = protocol === "tcp" ? b.acceptTcp() : b.acceptTrack(); + void next.catch(() => {}); + const reset = new StreamError(StreamCode(70)); + const oldReader = local instanceof socket.TcpStream ? local.consumer : local.consumer.ordered(); + remote.producer.abort(reset); + await expect(oldReader.readFrame()).rejects.toBeInstanceOf(Error); + const source = await broadcast(origin, "bob"); + let outcome: unknown; + const fetched = source + .track(`${protocol}/alice/bob/${local.session}`) + .fetchGroup(0n) + .then( + (value) => { + outcome = value; + }, + (error) => { + outcome = error; + }, + ); + try { + await clock.advance(Nano.zero); + const unverified = await next; + expect(unverified.session).toBe(local.session); + // A late query has the old name, but the original dial no longer answers. + // Verification must fail before a new producer can publish under that identity. + await expect(unverified.accept()).rejects.toBeInstanceOf(NotFound); + await fetched; + expect(outcome).toBeInstanceOf(Error); + const freshIncoming = protocol === "tcp" ? b.acceptTcp() : b.acceptTrack(); + const dialing = protocol === "tcp" ? a.dialTcp("bob") : a.dialTrack("bob"); + const incoming = await freshIncoming; + expect(incoming.session).not.toBe(local.session); + const remoteFresh = await incoming.accept(); + const localFresh = await dialing; + localFresh.producer.writeFrame(bytes(9)); + const frame = + remoteFresh instanceof socket.TcpStream + ? await remoteFresh.consumer.readFrame() + : await remoteFresh.consumer.ordered().readFrame(); + expect(frame?.payload).toEqual(bytes(9).payload); + await expect(oldReader.readFrame()).rejects.toBeInstanceOf(Error); + } finally { + a.close(); + b.close(); + await fetched; + } + }); +} + +test("a dial-only endpoint refuses incoming requests and answers only its reserved session", async () => { + const { origin, a } = setup(); + await expect(a.dialTcp("alice")).rejects.toBeInstanceOf(Error); + const unsupported = await raw(origin, "alice", "tcp/bob/alice/not-a-dial"); + await expect(unsupported.info()).rejects.toBeInstanceOf(NotFound); + unsupported.close(); +}); + +test("unknown and unaccepted protocols reset without entering the other queue", async () => { + const { origin, b } = setup(); + const hold = new AbortController(); + const incoming = b.acceptTcp({ signal: hold.signal }); + const rejected = incoming.catch((error: unknown) => error); + for (const protocol of ["track", "quic"]) { + const subscriber = await raw(origin, "bob", `${protocol}/alice/bob/session-${protocol}`); + await expect(subscriber.info()).rejects.toBeInstanceOf(Error); + subscriber.close(); + } + hold.abort(); + expect(await rejected).toBeInstanceOf(Error); +}); + +test("reject refuses one dial and leaves another independent session usable", async () => { + const { a, b } = setup(); + const incoming = b.acceptTcp(); + const refused = a.dialTcp("bob"); + const refusal = refused.catch((error: unknown) => error); + (await incoming).reject(); + expect(await refusal).toBeInstanceOf(NotFound); + const again = b.acceptTcp(); + const dialing = a.dialTcp("bob"); + const remote = await (await again).accept(); + const local = await dialing; + local.producer.writeFrame(bytes(42)); + expect((await remote.consumer.readFrame())?.payload).toEqual(bytes(42).payload); +}); + +test("cancelled dials leave queues and cannot be accepted later", async () => { + const clock = time(); + const { a, b } = setup(); + const next = b.acceptTcp(); + const cancel = new AbortController(); + const reason = new Error("gave up"); + const dialing = a.dialTcp("bob", { signal: cancel.signal }); + const failure = dialing.catch((error: unknown) => error); + const incoming = await next; + cancel.abort(reason); + expect(await failure).toBe(reason); + await expect(incoming.accept()).rejects.toBeInstanceOf(Error); + // Queue a dial after the application stopped pulling, then abandon it. + const pending = new AbortController(); + const dropped = a.dialTcp("bob", { signal: pending.signal }); + const observed = dropped.catch((error: unknown) => error); + await clock.advance(Nano.zero); + pending.abort(); + expect(await observed).toBeInstanceOf(Error); + const newIncoming = b.acceptTcp(); + const fresh = a.dialTcp("bob"); + const accepted = await newIncoming; + expect(accepted.session).not.toBe(incoming.session); + await Promise.all([accepted.accept(), fresh]); +}); + +test("dial and accept cancellation before announcement release their routing holds", async () => { + const clock = time(); + const { origin, a, b } = setup(); + const cancel = new AbortController(); + const dialing = a.dialTcp("later", { signal: cancel.signal }); + const failure = dialing.catch((error: unknown) => error); + await clock.advance(Nano.fromSecond(Second(3600))); + cancel.abort(); + expect(await failure).toBeInstanceOf(Error); + const next = b.acceptTcp(); + const subscriber = await raw(origin, "bob", "tcp/absent/bob/never-announced"); + const accepted = (await next).accept(); + const acceptanceFailure = accepted.catch((error: unknown) => error); + await clock.advance(Nano.fromSecond(Second(3600))); + subscriber.close(); + expect(await acceptanceFailure).toBeInstanceOf(Error); +}); + +test("a dial waits for the peer to announce, without an arbitrary timeout", async () => { + const clock = time(); + const { origin, a } = setup(); + const dialing = a.dialTcp("later"); + await clock.advance(Nano.fromSecond(Second(3600))); + const later = new socket.Endpoint(origin, Path.from("room"), "later"); + const incoming = await later.acceptTcp(); + const remote = await incoming.accept(); + const local = await dialing; + local.producer.writeFrame(bytes(1)); + expect((await remote.consumer.readFrame())?.payload).toEqual(bytes(1).payload); +}); + +test("accept waits for a delayed dialer announcement only while its original dial stays open", async () => { + const clock = time(); + const { origin, b } = setup(); + const next = b.acceptTrack(); + const subscriber = await raw(origin, "bob", "track/late/bob/delayed-announcement"); + const incoming = await next; + const acceptance = incoming.accept(); + await clock.advance(Nano.fromSecond(Second(3600))); + const route = origin.route(Path.from("room/.socket/late"), { hidden: true }); + const answer = hooks.reserveUnlistedTrack(route.broadcast, "track/late/bob/delayed-announcement"); + route.announce(); + await clock.advance(Nano.zero); + expect(answer.demand().wanted.peek()).toBeDefined(); + const producer = answer.accept({ maxAge: null }); + const remote = await acceptance; + producer.writeFrame(bytes(3)); + expect((await remote.consumer.ordered().readFrame())?.payload).toEqual(bytes(3).payload); + subscriber.close(); +}); + +test("forged and replayed dials cannot pass return routability", async () => { + const { origin, a, b } = setup(); + for (const name of ["tcp/alice/bob/forged", "tcp/alice/someone-else/replayed"]) { + const cancel = new AbortController(); + const wait = b.acceptTcp({ signal: cancel.signal }); + void wait.catch(() => {}); + const subscriber = await raw(origin, "bob", name); + const refusal = subscriber.info().catch((error: unknown) => error); + if (name.includes("/bob/")) await expect((await wait).accept()).rejects.toBeInstanceOf(NotFound); + else cancel.abort(); + expect(await refusal).toBeInstanceOf(NotFound); + subscriber.close(); + } + a.close(); +}); + +test("session names are absent from listings while reserved and established", async () => { + const { origin, a, b } = setup(); + const next = b.acceptTcp(); + const dialing = a.dialTcp("bob"); + const incoming = await next; + for (const id of ["alice", "bob"]) { + const listing = (await broadcast(origin, id)).tracks(); + expect(await listing.next()).toEqual({ kind: "synced" }); + listing.close(); + } + const remote = await incoming.accept(); + await dialing; + for (const id of ["alice", "bob"]) { + const listing = (await broadcast(origin, id)).tracks(); + expect(await listing.next()).toEqual({ kind: "synced" }); + listing.close(); + } + expect(remote.session).toBe(incoming.session); +}); + +test("TCP RST, reader cancellation, and endpoint lifetime reset both halves", async () => { + for (const action of ["abort", "reader", "endpoint"] as const) { + const { a, local, remote } = await tcp(); + const reset = new StreamError(StreamCode(70)); + if (action === "abort") local.producer.abort(reset); + if (action === "reader") local.consumer.close(); + if (action === "endpoint") a.close(); + await expect(remote.consumer.readFrame()).rejects.toBeInstanceOf(Error); + expect(await remote.producer.closed).toBeInstanceOf(Error); + } +}); + +test("track reset and endpoint close fail peers and leave no open accepts", async () => { + const { a, b, local, remote } = await track(); + local.producer.abort(new StreamError(StreamCode(71))); + await expect(remote.consumer.ordered().readFrame()).rejects.toBeInstanceOf(Error); + expect(await remote.producer.closed).toBeInstanceOf(Error); + const pending = b.acceptTcp(); + const failure = pending.catch((error: unknown) => error); + b.close(); + a.close(); + expect(await failure).toBeInstanceOf(Error); +}); + +test("a second TCP group resets the session even after the first FIN", async () => { + for (const initial of [0n, 2n]) { + const { origin, b } = setup(); + const route = origin.route(Path.from("room/.socket/raw")); + const name = `tcp/raw/bob/second-${initial}`; + const answer = hooks.reserveUnlistedTrack(route.broadcast, name); + const next = b.acceptTcp(); + const subscriber = await raw(origin, "bob", name); + const acceptance = (await next).accept(); + // Drive the accept's return subscription before publishing the first half. + const clock = time(); + await clock.advance(Nano.zero); + const producer = answer.accept(); + const stream = await acceptance; + const group = producer.createGroup(initial); + group.writeFrame(bytes(1)); + group.finish(); + if (initial === 0n) { + expect((await stream.consumer.readFrame())?.payload).toEqual(bytes(1).payload); + expect(await stream.consumer.readFrame()).toBeUndefined(); + producer.createGroup(1n).finish(); + } + expect(await stream.producer.closed).toBeInstanceOf(ProtocolViolation); + subscriber.close(); + } +}); + +for (const dropped of [false, true]) { + test(`TCP fails when its only group ${dropped ? "is dropped" : "never arrives"}`, async () => { + const { origin, b } = setup(); + const route = origin.route(Path.from("room/.socket/raw")); + const name = `tcp/raw/bob/missing-${dropped}`; + const answer = hooks.reserveUnlistedTrack(route.broadcast, name); + const next = b.acceptTcp(); + const subscriber = await raw(origin, "bob", name); + const acceptance = (await next).accept(); + const clock = time(); + await clock.advance(Nano.zero); + const producer = answer.accept(); + const stream = await acceptance; + const reading = stream.consumer.readFrame(); + if (dropped) hooks.dropTrackGroups(producer, 0n, 1n, new NotFound("TCP group unavailable")); + producer.finish(); + await expect(reading).rejects.toBeInstanceOf(NotFound); + expect(await stream.producer.closed).toBeInstanceOf(NotFound); + subscriber.close(); + }); +} + +test("an empty TCP group still delivers a clean FIN", async () => { + const { local, remote } = await tcp(); + local.producer.finish(); + remote.producer.finish(); + expect(await remote.consumer.readFrame()).toBeUndefined(); + expect(await local.consumer.readFrame()).toBeUndefined(); +}); + +test("unpaced TCP eviction is an error, never a skipped frame or clean FIN", async () => { + const clock = time(); + const { local, remote } = await tcp(); + // The local group's bounded tail is 32 MiB. Every payload is a distinct frame identity. + const payload = new Uint8Array(1024 * 1024); + for (let i = 0; i < 32; i++) { + local.producer.writeFrame({ payload }); + await clock.advance(Nano.zero); + } + local.producer.finish(); + await expect(remote.consumer.readFrame()).rejects.toMatchObject({ code: StreamCode.Evicted }); +}); + +test("endpoint ids refuse separators, and close is idempotent", async () => { + const { origin, a } = setup(); + for (const id of ["", "a/b", "a\\b"]) { + expect(() => new socket.Endpoint(origin, Path.from("room"), id)).toThrow(); + await expect(a.dialTcp(id)).rejects.toBeInstanceOf(Error); + } + a.close(); + a.close(); + await expect(a.acceptTcp()).rejects.toBeInstanceOf(Error); + await expect(a.dialTrack("bob")).rejects.toBeInstanceOf(Error); +}); + +for (const protocol of ["tcp", "track"] as const) { + const alpn = Lite.ALPN_07_WIP; + test(`socket ${protocol} preserves an early answer burst over ${alpn}`, async () => { + const clock = time(); + const ao = new Origin.Producer(); + const bo = new Origin.Producer(); + origins.add(ao); + origins.add(bo); + const a = new socket.Endpoint(ao, Path.from("room"), "alice"); + const route = bo.route(Path.from("room/.socket/bob"), { hidden: true }); + const requested = wireOf(route.broadcast).requested(); + route.announce(); + const pair = createMockTransportPair(alpn); + const url = new URL("https://localhost/socket"); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client, publish: ao.consume(), consume: ao }), + acceptConnection({ url, transport: pair.server, publish: bo.consume(), consume: bo }), + ]); + let returning: Track.Subscriber | undefined; + let drained: Promise | undefined; + const following = bo.follow(Path.from("room/.socket/alice")); + try { + const dialing = protocol === "tcp" ? a.dialTcp("bob") : a.dialTrack("bob"); + void dialing.catch(() => {}); + const request = await requested; + if (!request) throw new Error("missing dial"); + let caller = following.active.peek(); + while (!caller) { + await following.active.changed(); + caller = following.active.peek(); + } + returning = caller.track(request.name).subscribe({ + order: "ascending", + delay: { max: null }, + ranges: protocol === "track" ? [{ start: { absolute: 0n } }] : undefined, + }); + await returning.info(); + // The raw peer commits metadata only after writing its entire half, so + // SUBSCRIBE_OK carries the burst's last position, without a timing race. + hooks.keepRequest(request); + const staged = hooks.stageRequest(request, { maxAge: null }); + if (protocol === "tcp") { + const group = staged.producer.createGroup(0n); + for (let i = 1; i <= 3; i++) group.writeFrame(bytes(i)); + group.finish(); + } else { + for (let i = 1; i <= 3; i++) staged.producer.writeFrame(bytes(i)); + } + staged.producer.finish(); + staged.commit(); + await clock.advance(Nano.zero); + const local = await dialing; + const reader = local instanceof socket.TcpStream ? local.consumer : local.consumer.ordered(); + const received: number[] = []; + let completed = false; + let failure: unknown; + drained = (async () => { + for (let frame = await reader.readFrame(); frame; frame = await reader.readFrame()) + received.push(...frame.payload); + })().then( + () => { + completed = true; + }, + (error) => { + failure = error; + }, + ); + await clock.advance(Nano.zero); + expect(failure).toBeUndefined(); + expect(received).toEqual([1, 2, 3]); + expect(completed).toBe(true); + local.producer.finish(); + } finally { + returning?.close(); + following.close(); + a.close(); + route.broadcast.close(); + client.abort(); + server.abort(); + await drained; + } + }); +} + +for (const alpn of [Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_20, Ietf.ALPN.DRAFT_22]) { + for (const protocol of ["tcp", "track"] as const) { + test(`socket ${protocol} refusal reaches a routed dialer over ${alpn}`, async () => { + const clock = time(); + const ao = new Origin.Producer(); + const bo = new Origin.Producer(); + origins.add(ao); + origins.add(bo); + const a = new socket.Endpoint(ao, Path.from("room"), "alice"); + const b = new socket.Endpoint(bo, Path.from("room"), "bob"); + const pair = createMockTransportPair(alpn); + const url = new URL("https://localhost/socket"); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client, publish: ao.consume(), consume: ao }), + acceptConnection({ url, transport: pair.server, publish: bo.consume(), consume: bo }), + ]); + let outcome: unknown; + const next = protocol === "tcp" ? b.acceptTcp() : b.acceptTrack(); + const dialing = protocol === "tcp" ? a.dialTcp("bob") : a.dialTrack("bob"); + const settled = dialing.then( + (stream) => { + outcome = stream; + }, + (error) => { + outcome = error; + }, + ); + try { + (await next).reject(); + await clock.advance(Nano.zero); + expect(outcome).toBeInstanceOf(Error); + } finally { + a.close(); + b.close(); + client.abort(); + server.abort(); + await settled; + } + }); + } +} + +for (const alpn of [Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_18, Ietf.ALPN.DRAFT_20, Ietf.ALPN.DRAFT_22]) { + for (const protocol of ["tcp", "track"] as const) { + test(`pending socket ${protocol} acceptance over ${alpn} has no arbitrary deadline`, async () => { + const clock = time(); + const ao = new Origin.Producer(); + const bo = new Origin.Producer(); + origins.add(ao); + origins.add(bo); + const a = new socket.Endpoint(ao, Path.from("room"), "alice"); + const b = new socket.Endpoint(bo, Path.from("room"), "bob"); + const pair = createMockTransportPair(alpn); + const url = new URL("https://localhost/socket"); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client, publish: ao.consume(), consume: ao }), + acceptConnection({ url, transport: pair.server, publish: bo.consume(), consume: bo }), + ]); + const next = protocol === "tcp" ? b.acceptTcp() : b.acceptTrack(); + let outcome: unknown; + const dialing = protocol === "tcp" ? a.dialTcp("bob") : a.dialTrack("bob"); + const settled = dialing.then( + (value) => { + outcome = value; + }, + (error) => { + outcome = error; + }, + ); + try { + const incoming = await next; + await clock.advance(Nano.fromSecond(Second(3600))); + expect(outcome).toBeUndefined(); + const remote = await incoming.accept(); + const local = await dialing; + local.producer.writeFrame(bytes(7)); + const frame = + remote instanceof socket.TcpStream + ? await remote.consumer.readFrame() + : await remote.consumer.ordered().readFrame(); + expect(frame?.payload).toEqual(bytes(7).payload); + } finally { + a.close(); + b.close(); + client.abort(); + server.abort(); + await settled; + } + }); + } +} + +// A moq-lite client session before 07 never carries the endpoint's hidden broadcast, so its peer +// could never find it. Its dials and accepts fail as unsupported instead of waiting, as in Rust. +test("a socket endpoint behind a lite-06 client fails its dials and accepts, not the server's", async () => { + const clock = time(); + const ao = new Origin.Producer(); + const bo = new Origin.Producer(); + origins.add(ao); + origins.add(bo); + const a = new socket.Endpoint(ao, Path.from("room"), "alice"); + const b = new socket.Endpoint(bo, Path.from("room"), "bob"); + // Already waiting before the session attaches, as when an endpoint's client reconnects. + const waiting = a.dialTcp("bob").then( + () => undefined, + (error: unknown) => error, + ); + const pair = createMockTransportPair(Lite.ALPN_06); + const url = new URL("https://localhost/socket"); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client, publish: ao.consume(), consume: ao }), + acceptConnection({ url, transport: pair.server, publish: bo.consume(), consume: bo }), + ]); + let heard = false; + const next = b.acceptTcp().then( + () => { + heard = true; + }, + () => {}, + ); + try { + const unsupported = /unreachable behind a moq-lite hop before 07/; + expect(((await waiting) as Error).message).toMatch(unsupported); + await expect(a.dialTcp("bob")).rejects.toThrow(unsupported); + await expect(a.dialTrack("bob")).rejects.toThrow(unsupported); + await expect(a.acceptTcp()).rejects.toThrow(unsupported); + await expect(a.acceptTrack()).rejects.toThrow(unsupported); + // The server's endpoint keeps accepting, and never heard a dial it can't answer. + await clock.advance(Nano.fromSecond(Second(3600))); + expect(heard).toBe(false); + + // The session's end releases the endpoint: an accept waits again. + client.abort(); + await client.closed; + let accepted: unknown; + const accepting = a.acceptTcp().then( + (value) => { + accepted = value; + }, + (error: unknown) => { + accepted = error; + }, + ); + await clock.advance(Nano.zero); + expect(accepted).toBeUndefined(); + a.close(); + await accepting; + } finally { + client.abort(); + server.abort(); + a.close(); + b.close(); + await next; + } +}); diff --git a/js/net/src/socket.ts b/js/net/src/socket.ts new file mode 100644 index 00000000000..db76e74bdd7 --- /dev/null +++ b/js/net/src/socket.ts @@ -0,0 +1,620 @@ +/** + * Point-to-point sessions over one hidden broadcast per endpoint. + * + * Sessions use the existing MoQ wire and need lite-07 (currently an explicit + * `moq-lite-07-wip` offer) or moq-transport on every hop. While the endpoint's origin + * has a moq-lite client session before 07, dials and accepts fail as unsupported. + * Their track names are secrets and are never listed. See the socket concept page. + * + * @module + */ +import { type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; +import { NotFound, ProtocolViolation, StreamCode, StreamError } from "./error.ts"; +import * as Group from "./group.ts"; +import { hooks, SOCKET } from "./internal.ts"; +import type * as Origin from "./origin.ts"; +import * as Path from "./path.ts"; +import type * as Track from "./track.ts"; +import { untilAborted } from "./util/abort.ts"; +import { isUnrequested as abandoned } from "./util/demand.ts"; +import { fromBytes } from "./util/hex.ts"; +import { wireOf } from "./wire.ts"; + +type Protocol = "tcp" | "track"; +type Stream = TcpStream | TrackStream; +const reliable: Track.Subscription = { + order: "ascending", + delay: { max: null }, +}; +const cancelled = () => new StreamError(StreamCode.Cancel, { message: "socket operation cancelled" }); +const ended = () => new StreamError(StreamCode.SessionClosed, { message: "socket endpoint closed" }); +// Unsupported has no registered stream code, as in Rust: it maps to Internal. +const unreachable = () => new Error("socket endpoint is unreachable behind a moq-lite hop before 07"); + +function segment(id: string): void { + if (!id || id.includes("/") || id.includes("\\")) throw new TypeError("socket id must be one plain segment"); +} + +function endpointPath(prefix: Path.Valid, id: string): Path.Valid { + segment(id); + return Path.encode(Path.from(prefix, SOCKET, id)); +} + +function parse(name: string): { protocol: string; dialer: string; acceptor: string; session: string } | undefined { + const parts = name.split("/"); + if (parts.length !== 4 || parts.some((part) => !part || part.includes("\\"))) return; + const [protocol, dialer, acceptor, session] = parts; + return { protocol, dialer, acceptor, session }; +} + +// A cancellable signal wait removes its subscriptions even if the value never changes. +function waitFor(value: Getter, ready: (value: T) => boolean, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let dispose: Dispose = () => {}; + const finish = () => { + dispose(); + signal.removeEventListener("abort", abort); + }; + const abort = () => { + finish(); + reject(signal.reason); + }; + const check = () => { + const current = value.peek(); + if (!ready(current)) return; + finish(); + resolve(current); + }; + if (signal.aborted) return abort(); + dispose = value.subscribe(check); + signal.addEventListener("abort", abort, { once: true }); + check(); + }); +} + +/** Cancellation for a pending dial or accept; aborting after it resolves has no effect. */ +export interface Options { + /** Abandon this operation with the signal's reason. */ + signal?: AbortSignal; +} + +let makeTcp: (parts: Parts, producer: Group.Producer, consumer: Group.Consumer) => TcpStream; +let makeTrack: (parts: Parts) => TrackStream; +let makeIncoming: (endpoint: Endpoint, protocol: Protocol, request: Track.Request) => Incoming; +let accept: ( + endpoint: Endpoint, + protocol: Protocol, + request: Track.Request, + info: Track.InfoInit, + options?: Options, +) => Promise; + +/** A TCP session's one group each way; finish is FIN, and abort is RST. */ +export class TcpStream { + /** This side's writes, as frames in group zero. */ + readonly producer: Group.Producer; + /** The peer's writes in order; a reader falling behind the unpaced JS tail fails with eviction. */ + readonly consumer: Group.Consumer; + /** The peer endpoint's id. */ + readonly peer: string; + /** The fresh, secret session id chosen by the dialer. */ + readonly session: string; + + private constructor(parts: Parts, producer: Group.Producer, consumer: Group.Consumer) { + this.producer = producer; + this.consumer = consumer; + this.peer = parts.peer; + this.session = parts.session; + } + static { + makeTcp = (parts, producer, consumer) => new TcpStream(parts, producer, consumer); + } +} + +/** A track session whose ascending reader skips unavailable groups, with no default publisher max age. */ +export class TrackStream { + /** This side's track; write groups as independent messages. */ + readonly producer: Track.Producer; + /** The peer's subscribed track; ordered() skips groups that are refused, dropped, or evicted. */ + readonly consumer: Track.Subscriber; + /** The peer endpoint's id, verified by accepting the dial. */ + readonly peer: string; + /** The fresh, secret session id chosen by the dialer. */ + readonly session: string; + + private constructor(parts: Parts) { + this.producer = parts.producer; + this.consumer = parts.consumer; + this.peer = parts.peer; + this.session = parts.session; + } + static { + makeTrack = (parts) => new TrackStream(parts); + } +} + +interface Parts { + producer: Track.Producer; + consumer: Track.Subscriber; + peer: string; + session: string; +} + +/** An unverified incoming request to accept (checking its original dial is still open) or reject. */ +export class Incoming { + /** The id the dialer claims; accept verifies that endpoint really dialed this session. */ + readonly peer: string; + /** The claimed session id; only accept verifies that its original dial is still open. */ + readonly session: string; + #endpoint: Endpoint; + #protocol: Protocol; + #request?: Track.Request; + + private constructor(endpoint: Endpoint, protocol: Protocol, request: Track.Request) { + const name = parse(request.name); + if (!name) throw new Error("invalid incoming socket name"); + this.peer = name.dialer; + this.session = name.session; + this.#endpoint = endpoint; + this.#protocol = protocol; + this.#request = request; + } + static { + makeIncoming = (endpoint, protocol, request) => new Incoming(endpoint, protocol, request); + } + + /** Accept while the original dial stays open; track accepts may describe their outgoing half. */ + accept( + ...args: S extends TcpStream ? [options?: Options] : [info?: Track.InfoInit, options?: Options] + ): Promise { + const request = this.#request; + this.#request = undefined; + if (!request) return Promise.reject(cancelled()); + const [info, options] = + this.#protocol === "tcp" + ? [{}, args[0] as Options | undefined] + : [(args[0] as Track.InfoInit) ?? { maxAge: null }, args[1]]; + return accept(this.#endpoint, this.#protocol, request, info, options) as Promise; + } + + /** Refuse this dial as not found; idempotent. */ + reject(): void { + this.#request?.reject(new NotFound("socket session")); + this.#request = undefined; + } +} + +class Queue { + listening = false; + dials = new Signal(new Map()); + close(): void { + for (const [request, dispose] of this.dials.peek()) { + dispose(); + request.reject(ended()); + } + this.dials.mutate((dials) => dials.clear()); + } +} + +/** + * Announces one endpoint, dials peers under the same prefix, and accepts each protocol separately. + * + * Every hop between two endpoints must carry hidden broadcasts: moq-lite-07 or moq-transport. + * While the origin has a moq-lite client session before 07, whose peer never hears this + * endpoint, dials and accepts fail as unsupported. Nothing tells a dialer that its peer sits + * behind such a hop, so a dial to it waits as for a peer not announced yet. + */ +export class Endpoint { + /** This endpoint's id, a single plain segment. */ + readonly id: string; + /** The shared prefix containing the endpoints. */ + readonly prefix: Path.Valid; + /** Settles when the endpoint closes. */ + get closed(): GetPromise { + return this.#closed; + } + #closed = new Once(); + #abort = new AbortController(); + #origin: Origin.Producer; + #route: Origin.Route; + #queues = { tcp: new Queue(), track: new Queue() }; + #serving = false; + #sessions = new Set(); + #unclose: Dispose; + #withheld: Getter; + + /** Publish this endpoint on origin, which must also reach its peers. */ + constructor(origin: Origin.Producer, prefix: Path.Valid, id: string) { + this.id = id; + this.prefix = Path.from(prefix); + this.#origin = origin; + this.#withheld = hooks.hiddenWithheld(origin); + this.#route = origin.route(endpointPath(this.prefix, id)); + this.#unclose = hooks.onClosed(this.#route.broadcast, () => this.close()); + } + + static { + accept = (endpoint, protocol, request, info, options) => endpoint.#accept(protocol, request, info, options); + } + + /** Dial a TCP session; waits for the peer's announcement and acceptance until cancelled. */ + dialTcp(peer: string, options?: Options): Promise { + return this.#dial("tcp", peer, {}, options) as Promise; + } + /** Dial a track session; omitted info keeps every message with no publisher max age. */ + dialTrack(peer: string, info: Track.InfoInit = { maxAge: null }, options?: Options): Promise { + return this.#dial("track", peer, info, options) as Promise; + } + /** Take the next TCP dial; the first call starts queueing TCP dials until close. */ + acceptTcp(options?: Options): Promise> { + return this.#next("tcp", options); + } + /** Take the next track dial; its queue is independent of TCP's. */ + acceptTrack(options?: Options): Promise> { + return this.#next("track", options); + } + + async #next(protocol: Protocol, options?: Options): Promise> { + const abort = new AbortController(); + const signal = AbortSignal.any([this.#signal(options), abort.signal]); + const unwatch = this.#reachable(abort); + try { + signal.throwIfAborted(); + const queue = this.#queues[protocol]; + queue.listening = true; + if (!this.#serving) { + this.#serving = true; + void this.#serve(); + } + for (;;) { + await waitFor(queue.dials, (dials) => dials.size > 0, signal); + signal.throwIfAborted(); + const first = queue.dials.peek().entries().next().value; + if (!first) continue; + const [request, dispose] = first; + dispose(); + queue.dials.mutate((dials) => dials.delete(request)); + if (!abandoned(request)) return makeIncoming(this, protocol, request); + request.reject(cancelled()); + } + } finally { + unwatch(); + } + } + + // Abort with unsupported while a session withholds this endpoint from its peer, until disposed. + #reachable(abort: AbortController): Dispose { + const check = () => { + if (this.#withheld.peek() > 0) abort.abort(unreachable()); + }; + const unwatch = this.#withheld.subscribe(check); + check(); + return unwatch; + } + + async #serve(): Promise { + try { + for (;;) { + const request = await wireOf(this.#route.broadcast).requested(); + if (!request) return; + const name = parse(request.name); + if (!name || name.acceptor !== this.id || name.dialer === this.id) { + request.reject(new NotFound("socket session")); + continue; + } + if ((name.protocol !== "tcp" && name.protocol !== "track") || !this.#queues[name.protocol].listening) { + // Unsupported has no registered stream code, as in Rust: it maps to Internal. + request.reject(new Error("socket protocol is unsupported")); + continue; + } + const queue = this.#queues[name.protocol]; + const demand = request.demand(); + const prune = () => { + if (!abandoned(request)) return; + queue.dials.peek().get(request)?.(); + queue.dials.mutate((dials) => dials.delete(request)); + request.reject(cancelled()); + }; + const stop = hooks.requestWanted(request).subscribe(prune); + const release = hooks.demandReleased(demand).subscribe(prune); + queue.dials.mutate((dials) => + dials.set(request, () => { + stop(); + release(); + }), + ); + prune(); + } + } catch (error) { + this.#end(error instanceof Error ? error : new Error(String(error))); + } + } + + #signal(options?: Options): AbortSignal { + return options?.signal ? AbortSignal.any([this.#abort.signal, options.signal]) : this.#abort.signal; + } + + async #remote( + protocol: Protocol, + peer: string, + name: string, + signal: AbortSignal, + hold: (following: Origin.Following) => void, + ): Promise<{ consumer: Track.Subscriber; peer: GetPromise }> { + const following = this.#origin.follow(endpointPath(this.prefix, peer)); + hold(following); + const abort = new AbortController(); + const stop = following.closed.subscribe(() => abort.abort(following.closed.peek() ?? ended())); + if (following.closed.peek() !== undefined) abort.abort(following.closed.peek() ?? ended()); + const pending = AbortSignal.any([signal, abort.signal]); + try { + const broadcast = await waitFor(following.active, (active) => active !== undefined, pending); + if (!broadcast) throw ended(); + return { + consumer: broadcast.track(name).subscribe({ + ...reliable, + ranges: protocol === "track" ? [{ start: { absolute: 0n } }] : undefined, + }), + peer: broadcast.closed, + }; + } finally { + stop(); + } + } + + async #dial(protocol: Protocol, peer: string, info: Track.InfoInit, options?: Options): Promise { + segment(peer); + if (peer === this.id) throw new Error("dialing the same socket endpoint is unsupported"); + const abort = new AbortController(); + const signal = AbortSignal.any([this.#signal(options), abort.signal]); + signal.throwIfAborted(); + const session = fromBytes(crypto.getRandomValues(new Uint8Array(16))); + const name = `${protocol}/${this.id}/${peer}/${session}`; + const answer = hooks.reserveUnlistedTrack(this.#route.broadcast, name); + let following: Origin.Following | undefined; + let consumer: Track.Subscriber | undefined; + let producer: Track.Producer | undefined; + let stop: Dispose = () => {}; + const unreached = this.#reachable(abort); + try { + const remote = await this.#remote(protocol, peer, name, signal, (value) => { + following = value; + }); + consumer = remote.consumer; + stop = hooks.watchSubscriberError(consumer, (error) => abort.abort(error)); + const serving = waitFor(hooks.requestWanted(answer), (wanted) => wanted, signal).then(() => { + producer = answer.accept(info); + }); + await untilAborted(Promise.all([serving, consumer.info()]), signal); + signal.throwIfAborted(); + if (!producer || !following) throw cancelled(); + return this.#open(protocol, { producer, consumer, peer, session }, following, remote.peer); + } catch (error) { + answer.reject(error instanceof Error ? error : cancelled()); + consumer?.close(); + following?.close(); + throw error; + } finally { + stop(); + unreached(); + abort.abort(cancelled()); + } + } + + async #accept( + protocol: Protocol, + request: Track.Request, + info: Track.InfoInit, + options?: Options, + ): Promise { + const name = parse(request.name); + if (!name) throw cancelled(); + const abort = new AbortController(); + const signal = AbortSignal.any([this.#signal(options), abort.signal]); + const demand = request.demand(); + const check = () => { + if (abandoned(request)) abort.abort(cancelled()); + }; + const stop = hooks.requestWanted(request).subscribe(check); + const release = hooks.demandReleased(demand).subscribe(check); + const unreached = this.#reachable(abort); + let following: Origin.Following | undefined; + let consumer: Track.Subscriber | undefined; + let stopError: Dispose = () => {}; + try { + check(); + signal.throwIfAborted(); + const remote = await this.#remote(protocol, name.dialer, request.name, signal, (value) => { + following = value; + }); + consumer = remote.consumer; + stopError = hooks.watchSubscriberError(consumer, (error) => abort.abort(error)); + await untilAborted(consumer.info(), signal); + signal.throwIfAborted(); + if (!following) throw cancelled(); + hooks.keepRequest(request); + return this.#open( + protocol, + { producer: request.accept(info), consumer, peer: name.dialer, session: name.session }, + following, + remote.peer, + ); + } catch (error) { + request.reject(error instanceof Error ? error : cancelled()); + consumer?.close(); + following?.close(); + throw error; + } finally { + stop(); + release(); + unreached(); + stopError(); + } + } + + #open(protocol: Protocol, parts: Parts, following: Origin.Following, peer: GetPromise): Stream { + this.#abort.signal.throwIfAborted(); + if (following.active.peek()?.closed !== peer) throw ended(); + const session = new Session(parts, following, peer, () => this.#sessions.delete(session)); + this.#sessions.add(session); + try { + return protocol === "tcp" ? session.tcp() : session.track(); + } catch (error) { + session.reset(error instanceof Error ? error : cancelled()); + throw error; + } + } + + /** Reset every held session, cancel pending operations, and retract the endpoint; idempotent. */ + close(): void { + this.#end(null); + } + + #end(error: Error | null): void { + if (this.#closed.peek() !== undefined) return; + this.#closed.set(error); + this.#unclose(); + this.#abort.abort(error ?? ended()); + for (const queue of Object.values(this.#queues)) queue.close(); + for (const session of this.#sessions) session.reset(ended()); + this.#route.broadcast.close(); + } +} + +// Owns the routing hold, monitors both halves, and ends the TCP copy on protocol violations. +class Session { + #parts: Parts; + #following: Origin.Following; + #peer: GetPromise; + #done: Dispose; + #abort = new AbortController(); + #stops: Dispose[] = []; + #local?: Group.Producer; + #outgoing?: Group.Producer; + #sent = false; + #received = false; + constructor(parts: Parts, following: Origin.Following, peer: GetPromise, done: Dispose) { + this.#parts = parts; + this.#following = following; + this.#peer = peer; + this.#done = done; + } + + #hold(stop: Dispose): void { + if (this.#abort.signal.aborted) stop(); + else this.#stops.push(stop); + } + + #monitor(): void { + // A socket reset ends the session even when a generic routed reader would recover. + this.#hold(hooks.watchSubscriberError(this.#parts.consumer, (error) => this.reset(error))); + const check = () => { + if (this.#following.active.peek()?.closed !== this.#peer) this.reset(ended()); + }; + this.#hold(this.#following.active.subscribe(check)); + check(); + } + + #watch(value: GetPromise, done: () => void): void { + const check = (error: Error | null | undefined) => { + if (error === undefined) return; + if (error) this.reset(error); + else { + done(); + this.#complete(); + } + }; + this.#hold(value.subscribe(check)); + check(value.peek()); + } + + track(): TrackStream { + this.#monitor(); + this.#watch(this.#parts.producer.closed, () => { + this.#sent = true; + }); + this.#watch(this.#parts.consumer.closed, () => { + this.#received = true; + }); + return makeTrack(this.#parts); + } + + tcp(): TcpStream { + const { producer, consumer } = this.#parts; + const outgoing = producer.createGroup(0n); + const local = new Group.Producer(0n); + const incoming = local.consume(); + this.#local = local; + this.#outgoing = outgoing; + this.#monitor(); + this.#watch(outgoing.closed, () => { + producer.finish(); + this.#sent = true; + }); + this.#watch(producer.closed, () => {}); + this.#watch(consumer.closed, () => {}); + const check = () => { + if (!local.demand().wanted.peek() && !this.#received) this.reset(cancelled()); + }; + this.#hold(local.demand().wanted.subscribe(check)); + void this.#copy().catch((error) => this.reset(error instanceof Error ? error : cancelled())); + void this.#groups().catch((error) => this.reset(error instanceof Error ? error : cancelled())); + return makeTcp(this.#parts, outgoing, incoming); + } + + async #copy(): Promise { + const ordered = this.#parts.consumer.ordered(); + const group = await ordered.nextGroup(); + // Even an empty TCP half publishes group 0. The ordered cursor skips lost + // groups, so reaching track end without it is loss, not a clean FIN. + if (!group) throw new NotFound("TCP session group"); + try { + if (group.sequence !== 0n) throw new ProtocolViolation("second group on a TCP session"); + for (;;) { + const frame = await group.readFrame(); + if (!frame) break; + this.#local?.writeFrame({ payload: frame.payload }); + } + } finally { + group.close(); + } + if (this.#abort.signal.aborted) return; + this.#received = true; + this.#local?.finish(); + this.#complete(); + } + + async #groups(): Promise { + for (;;) { + const largest = await this.#parts.consumer.largest(); + if (!largest) return; + if (largest.group > 0n) throw new ProtocolViolation("second group on a TCP session"); + } + } + + #complete(): void { + if (this.#sent && this.#received) this.#close(false); + } + + reset(error: Error): void { + if (this.#abort.signal.aborted) return; + this.#outgoing?.abort(error); + this.#local?.abort(error); + this.#parts.producer.abort(error); + this.#parts.consumer.close(error); + this.#close(true); + } + + #close(reset: boolean): void { + if (this.#abort.signal.aborted) return; + this.#abort.abort(); + for (const stop of this.#stops) stop(); + this.#stops.length = 0; + // A settled end retains its cache for existing readers, while releasing the + // broadcast's unlisted reservation instead of accumulating completed sessions. + this.#parts.producer.close(); + if (reset || this.#local) this.#parts.consumer.close(); + this.#following.close(); + this.#done(); + } +} diff --git a/js/net/src/subscribe_priority.test.ts b/js/net/src/subscribe_priority.test.ts index 1ccae6bdb62..caa93dff11f 100644 --- a/js/net/src/subscribe_priority.test.ts +++ b/js/net/src/subscribe_priority.test.ts @@ -60,9 +60,8 @@ test.each([Lite.ALPN_07_WIP, ...Object.values(Ietf.ALPN)])( "%s follows the aggregate priority upstream", async (protocol) => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const [client, server] = await session(protocol, origin); @@ -100,9 +99,8 @@ test.each(Object.values(Ietf.ALPN))("%s ends a subscription mid-update without a await yieldNow(); }); const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const [client, server] = await session(protocol, origin); let closed: Error | null | undefined; @@ -137,9 +135,8 @@ test.each(Object.values(Ietf.ALPN))("%s ends a subscription mid-update without a // update of defaults first, as on lite and in Rust. test.each(Object.values(Ietf.ALPN))("%s sends no update when the last subscriber leaves", async (protocol) => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const [client, server] = await session(protocol, origin); try { @@ -164,9 +161,8 @@ test.each(Object.values(Ietf.ALPN))("%s sends no update when the last subscriber // track with the error rather than leaving it at a stale priority. Nothing reaches the wire. test.each(Object.values(Ietf.ALPN))("%s ends the track when an update fails to encode", async (protocol) => { const origin = new OriginProducer(); - const broadcastPublication = origin.createBroadcast(path); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(path); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const [client, server] = await session(protocol, origin); let closed: Error | null | undefined; diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index 40a81d606c1..b354df703ae 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -26,9 +26,8 @@ async function session(protocol: string) { connect({ url, transport: pair.client }), accept({ transport: pair.server, url, publish: origin.consume() }), ]); - const broadcastPublication = origin.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; const video = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: null }); const remote = wireOf(client).consume(Path.from("test")); const reader = remote diff --git a/js/net/src/terminal.test.ts b/js/net/src/terminal.test.ts index 796ae45243d..009cfc2a9b6 100644 --- a/js/net/src/terminal.test.ts +++ b/js/net/src/terminal.test.ts @@ -57,8 +57,7 @@ test("an unanswered track request is refused with the dropped producer's error", test("a lite protocol violation reaches the subscribed track and its open group", async () => { const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); - const publication = origin.createBroadcast(Path.from("room")); - publication.announce(); + const publication = origin.route(Path.from("room")); const producer = publication.broadcast.createTrack("video", { timescale: Timescale.MILLI }); producer.appendGroup().writeFrame(textFrame("prefix")); const url = new URL("https://example.com"); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index b38756d3f11..cdd2f1945ee 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -5,7 +5,7 @@ */ import { Instant, Milli, Nano, now, sleepUntil } from "@moq/clock"; -import { type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; +import { Derived, type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import { Delivered } from "./delivered.ts"; import { GroupTooLarge, @@ -434,7 +434,7 @@ function smallest(a: Milli | null | undefined, b: Milli | null | undefined): Mil return Milli(Math.min(a, b)); } -// Aggregate the preferences of every live subscriber, matching Rust's Subscription::poll_combined. +// Aggregate the preferences of `states`, matching Rust's Subscription::poll_combined. function combineSubscriptions(states: Iterable): Subscription | undefined { let combined: Subscription | undefined; for (const state of states) { @@ -476,17 +476,33 @@ export class Request { #producer: Producer; #pending: Set; + #lookups?: TrackRequestOptions["lookups"]; + #wanted?: Getter; private constructor(options: TrackRequestOptions) { this.name = options.name; this.#producer = options.producer; this.#pending = options.pending; + this.#lookups = options.lookups; this.#pending.add(this); } static { hooks.makeRequest = (options) => new Request(options); hooks.stageRequest = (request, info) => request.#stage(info); + hooks.requestWanted = (request) => { + const wanted = request.#producer.demand().wanted; + request.#wanted ??= request.#lookups + ? new Derived( + [wanted, request.#lookups.pending], + (group, lookups) => group !== undefined || lookups > 0, + ) + : new Derived([wanted], (group) => group !== undefined); + return request.#wanted; + }; + hooks.keepRequest = (request) => { + if (request.#lookups) request.#lookups.opened = false; + }; } /** Watch the subscribers waiting for this request. */ @@ -494,9 +510,9 @@ export class Request { return this.#producer.demand(); } - /** The aggregate subscription requested for this track. */ + /** The aggregate subscription requested for this track, one-shot fetches included. */ get subscription(): Readonly { - return this.#producer.subscription.peek() ?? subscriptionDefaults(); + return hooks.requested(this.#producer).peek() ?? subscriptionDefaults(); } /** The subscriber's priority for this track. */ @@ -536,6 +552,12 @@ export class Request { } } +/** Options for {@link Consumer.info}. */ +export interface InfoOptions { + /** Abandons the lookup, rejecting with the signal's reason; aborting after it resolves has no effect. */ + signal?: AbortSignal; +} + /** Options for {@link Consumer.fetchGroup}. */ export interface FetchGroupOptions { /** Delivery priority for the fetch stream. Defaults to `0`. */ @@ -587,9 +609,19 @@ export class Consumer { return this.#broadcast.subscribe(this.name, options); } - /** Fetch the track's immutable publisher properties without subscribing. */ - info(): Promise { - return this.#broadcast.resolveTrackInfo(this.name); + /** + * Fetch the track's immutable publisher properties without subscribing. It waits for as long + * as the publisher takes to answer: abort `options.signal` to give up. + */ + info(options?: InfoOptions): Promise { + const signal = options?.signal; + if (!signal) return this.#broadcast.resolveTrackInfo(this.name); + if (signal.aborted) return Promise.reject(signal.reason); + // Held only until the answer, so the signal abandons the lookup but never keeps the track. + const answered = new AbortController(); + return this.#broadcast + .resolveTrackInfo(this.name, AbortSignal.any([signal, answered.signal])) + .finally(() => answered.abort()); } /** Fetch a single group by sequence without holding a live subscription. */ @@ -788,6 +820,9 @@ let reofferCached: ( let reofferSequences: (producer: Producer, sink: TrackState, sequences: readonly bigint[]) => void; // Whether a group may still land on the producer's live feed; assigned in its static block. let mayLand: (producer: Producer, land: Land) => boolean; +// Recompute the producer's aggregates now, for a sink that just became a fetch; assigned in its +// static block. +let resubscribe: (producer: Producer) => void; let owedRuns: (producer: Producer, start: bigint, end: bigint) => Iterable; let owedGroup: (producer: Producer, start: bigint, end: bigint) => { sequence: bigint; folds?: number } | undefined; let fillEdge: (producer: Producer) => bigint | undefined; @@ -897,6 +932,10 @@ export class Producer { // `max_sequence`, and where the next append lands. #received = 0n; #subscribing = new Signal(false); + // The aggregate across live subscribers only. A fetch's preferences judge just the group it + // asks for, so a publisher sizing what it serves (such as how much retained media to replay) + // must not see them. The wire's aggregate, `#state.update`, keeps them. + #live = new Signal(undefined); // Recently written source groups, retained for replay to late subscribers and // pruned once idle for longer than the cache window. Each entry tracks the mirror @@ -979,6 +1018,8 @@ export class Producer { static { hooks.subscribing = (producer) => producer.#subscribing; + hooks.requested = (producer) => producer.#state.update; + resubscribe = (producer) => producer.#updateSubscription(); hooks.cacheTrack = (producer) => { producer.#subscribed = false; producer.#served = undefined; @@ -1175,12 +1216,9 @@ export class Producer { return this.#state.outcome; } - /** - * The aggregate subscription across live subscribers, or `undefined` when there are none. - * The wire layer watches this to emit SUBSCRIBE_UPDATE. - */ + /** The aggregate subscription across live subscribers, or `undefined` when there are none. */ get subscription(): Getter { - return this.#state.update; + return this.#live; } /** @@ -1334,17 +1372,19 @@ export class Producer { } } - // Recompute from every live sink because an update or close can narrow as well as widen - // the aggregate. The wire layer observes this signal and emits SUBSCRIBE_UPDATE. + // Recompute from every sink because an update or close can narrow as well as widen the + // aggregates. The wire layer observes `#state.update` and emits SUBSCRIBE_UPDATE. // // The max delay is the subscribers' own, not clamped to the track's maxAge: the publisher // applies its own, as each reader's budget does, so the demand asked upstream is the same // before and after the info lands. #updateSubscription(): void { - this.#subscribing.set([...this.#sinks].some((sink) => !sink.fetch && sink.closed.peek() === undefined)); + const live = [...this.#sinks].filter((sink) => !sink.fetch); + this.#subscribing.set(live.some((sink) => sink.closed.peek() === undefined)); const combined = combineSubscriptions(this.#sinks); this.#settle(); this.#state.update.set(combined); + this.#live.set(combineSubscriptions(live)); // The requested floor bounds what a pending tail owes. this.#owe(); } @@ -2891,6 +2931,15 @@ export class Subscriber { }); return subscriber; }; + hooks.watchSubscriberError = (subscriber, fn) => { + if (subscriber.#resume) return subscriber.#resume.watchError(fn); + const check = (error: Error | null | undefined) => { + if (error instanceof Error) fn(error); + }; + const stop = subscriber.closed.subscribe(check); + check(subscriber.closed.peek()); + return stop; + }; hooks.latestSequence = (subscriber) => subscriber.#resume ? subscriber.#resume.latest() : subscriber.#state.latest; hooks.bufferedLargest = (subscriber) => subscriber.#bufferedLargest(); @@ -2931,6 +2980,8 @@ export class Subscriber { hooks.fetchBudget = (subscriber, maxDelay) => { subscriber.#fetchBudget = maxDelay ?? Number.POSITIVE_INFINITY; subscriber.#state.fetch = true; + // The sink joined as a live subscriber; leave the live aggregate before anyone sees it. + if (subscriber.#state.producer) resubscribe(subscriber.#state.producer); const update = subscriber.#state.update.peek(); if (update) subscriber.#state.update.set(update, true); }; diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index 2ce19df361d..eae4fe355e2 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -44,9 +44,8 @@ async function roundTrip(wire: Wire, timescale: Timescale | undefined, timestamp accept({ transport: pair.server, url, version: wire.version, publish: origin.consume() }), ]); - const broadcastPublication = origin.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = origin.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; // Every request is answered, including the TRACK lookup lite-05+ makes before subscribing. const serving = (async () => { for (;;) { @@ -115,9 +114,8 @@ test("a forwarded untimed track stays untimed", async () => { connect({ url, transport: downstream.client }), ]); - const broadcastPublication = source.createBroadcast(Path.from("test")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce(); + const broadcastRoute = source.route(Path.from("test")); + const broadcast = broadcastRoute.broadcast; const serving = (async () => { for (;;) { const request = await wireOf(broadcast).requested(); @@ -130,7 +128,7 @@ test("a forwarded untimed track stays untimed", async () => { // Serve the downstream session from the upstream one through a copy the forwarding // node produces. - const dynamic = relay.requestsRoute(Path.from("test")); + const dynamic = relay.routePrefix(Path.from("test")); const inputs: BroadcastConsumer[] = []; const forwarding = (async () => { for await (const request of dynamic.requested()) { diff --git a/js/net/src/util/deadline.ts b/js/net/src/util/deadline.ts index cfc810f3260..284e3791e7e 100644 --- a/js/net/src/util/deadline.ts +++ b/js/net/src/util/deadline.ts @@ -1,10 +1,10 @@ -import { Milli, Nano, sleep } from "@moq/clock"; +import { type Milli, Nano, sleep } from "@moq/clock"; import { race } from "@moq/signals"; import { controlTimeout, error } from "../error.ts"; import type { Stream } from "../stream.ts"; import { TimeoutError } from "./timeout.ts"; -/** Milliseconds a request may go unanswered once none of its streams waits to open. @internal */ +/** Milliseconds an advertisement may go unanswered while none of its streams waits to open. @internal */ export const REQUEST_TIMEOUT_MS = 10_000; /** What a deadline failure resets: a stream, or whatever owns one. @internal */ @@ -13,10 +13,9 @@ export interface Abortable { } /** - * One fatal deadline for a request, including every stream its setup creates. - * - * An open waiting for the peer's stream credit is waiting its turn, not the peer stalling, so - * the deadline only runs while none of the request's streams waits to open. @internal + * Owns a request's setup streams and optionally bounds its answer. Track requests take no + * budget: their publisher may hold the answer until demand, cancellation, or the session + * ends it. A budget runs only while none of the request's streams waits to open. @internal */ export class Deadline { /** Creates still waiting on credit: a fetch opens its TRACK stream alongside. */ @@ -27,7 +26,10 @@ export class Deadline { #owners: Abortable[] = []; #failure?: Error; - constructor() { + #budget?: Milli; + + constructor(budget?: Milli) { + this.#budget = budget; this.#rearm(); } @@ -76,11 +78,13 @@ export class Deadline { await write(); } - /** Wait for the first answer under the request's single deadline. */ + /** Wait for the first answer, within the budget if one was given. */ async wait(answer: Promise): Promise { + const budget = this.#budget; + if (budget === undefined) return answer; const done = new AbortController(); try { - return (await race([answer, this.#expire(done.signal)])) as T; + return (await race([answer, this.#expire(budget, done.signal)])) as T; } catch (cause) { const failure = cause instanceof TimeoutError ? controlTimeout(cause) : error(cause); this.#failure = failure; @@ -95,8 +99,8 @@ export class Deadline { } /** Reject once the request goes the whole budget with none of its streams waiting to open. */ - async #expire(done: AbortSignal): Promise { - const budget = Nano.fromMilli(Milli(REQUEST_TIMEOUT_MS)); + async #expire(limit: Milli, done: AbortSignal): Promise { + const budget = Nano.fromMilli(limit); const answered = new Promise((resolve) => done.addEventListener("abort", () => resolve(), { once: true }), ); @@ -116,7 +120,7 @@ export class Deadline { // Answered: the waiting is over, and nothing is left to reject. if (why === undefined) return new Promise(() => {}); if (why === "expired" && !done.aborted) { - throw new TimeoutError(`request timed out after ${REQUEST_TIMEOUT_MS}ms`); + throw new TimeoutError(`request timed out after ${this.#budget}ms`); } } } diff --git a/js/net/src/util/demand.ts b/js/net/src/util/demand.ts index 4f9a2384604..e9d37a12a8f 100644 --- a/js/net/src/util/demand.ts +++ b/js/net/src/util/demand.ts @@ -1,6 +1,6 @@ import { Signal } from "@moq/signals"; import { hooks } from "../internal.ts"; -import type { Demand } from "../track.ts"; +import type { Demand, Request } from "../track.ts"; /** Resolve once no subscriber wants the track, or it closed. */ export async function unwanted(demand: Demand): Promise { @@ -23,6 +23,18 @@ export async function abandoned(demand: Demand): Promise { } } +/** Resolve once neither a subscriber nor an info lookup wants the request, or its track closed. */ +export async function unrequested(request: Request): Promise { + const wanted = hooks.requestWanted(request); + const released = hooks.demandReleased(request.demand()); + while (!isUnrequested(request)) await Signal.race(wanted, released); +} + +/** Whether neither a subscriber nor an info lookup wants the request, or its track closed. */ +export function isUnrequested(request: Request): boolean { + return !hooks.requestWanted(request).peek() || hooks.demandReleased(request.demand()).peek() !== undefined; +} + /** Whether no subscriber wants the track now, or it closed. */ export function isAbandoned(demand: Demand): boolean { return hooks.demandReleased(demand).peek() !== undefined || demand.wanted.peek() === undefined; diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index d8a7e59e99c..c39b99474b1 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -11,7 +11,7 @@ import type { Dispose, GetPromise, Getter } from "@moq/signals"; import type * as broadcast from "./broadcast.ts"; import type * as datagram from "./datagram.ts"; import type { Consumer as GroupConsumer } from "./group.ts"; -import type { Route } from "./hop.ts"; +import type { RouteInfo } from "./hop.ts"; import type * as Instance from "./instance.ts"; import type { Lister, Tracks } from "./listing.ts"; import type * as origin from "./origin.ts"; @@ -42,6 +42,12 @@ export interface Broadcast { /** Receives one change to the watched prefixes: `prefix` is now (or no longer) wanted. */ export type InterestListener = (prefix: Path.Valid, wanted: boolean) => void; +/** An origin's open request for a path, as a serving session sees it. */ +export interface RequestSlot { + /** The blind answer a session already gave it, if any. */ + readonly answer?: broadcast.Consumer; +} + /** The protocol-facing operations behind an origin producer. */ export interface OriginProducer { /** @@ -53,32 +59,34 @@ export interface OriginProducer { interests(listener: InterestListener): Dispose; /** Whether an advertised prefix overlaps the handle's allowed paths. */ accepts(prefix: Path.Valid): boolean; - receive( - prefix: Path.Valid, - route?: - | Route - | { - instance?: Route["instance"]; - epoch?: Route["epoch"]; - hops?: Route["hops"]; - cost?: Route["cost"]; - auth?: Route["auth"]; - }, - ): origin.Requests; + /** Land a route a peer announced, taken as given and never forwarded back to a peer. */ + receive(prefix: Path.Valid, route?: Partial): origin.Route; attach(discovery: boolean): Dispose; /** * Hold the origin's announcement streams overlapping `prefix` unsynced until * the returned dispose: a session holds one while its peer's initial set is in flight. */ snapshot(prefix: Path.Valid): Dispose; - readonly requests: Getter | undefined>; + readonly requests: Getter | undefined>; changed(): GetPromise; /** Whether sessions should answer the request with a blind subscription right now. */ - blind(slot: origin.RequestSlot): boolean; + blind(slot: RequestSlot): boolean; answer(path: Path.Valid, front: broadcast.Consumer): Dispose | undefined; routes(path: Path.Valid): boolean; } +/** The protocol-facing operations behind an origin route. */ +export interface Route { + /** The metadata the route is advertised with, or undefined while hidden. */ + readonly info: RouteInfo | undefined; + /** + * Advertise the route with `info` as given, as a session's update does: a named instance + * replaces the route's, which restarts its readers; otherwise it keeps the route's own. + * Announces a hidden route. + */ + update(info: Partial): void; +} + /** The protocol-facing operations behind an origin consumer. */ export interface OriginConsumer { routes(path: Path.Valid): boolean; @@ -93,7 +101,7 @@ export interface OriginConsumer { /** One originated advertisement exposed to the publishing wire. */ export interface Advertised { readonly identity: object; - readonly route: Route; + readonly route: RouteInfo; /** The paths a scoped route may serve beneath its prefix, relative like its key; unset for the whole subtree. */ readonly claim?: Path.Patterns; /** @@ -107,7 +115,7 @@ export interface Advertised { /** What names a stream: an advertisement, and the instance its route serves. */ export interface Served { readonly identity: object; - readonly route: Pick; + readonly route: Pick; } /** @@ -145,7 +153,7 @@ export interface Established { /** A produced broadcast's view, which always fetches from its own cache. */ export type Produced = Broadcast & Required>; -type View = Broadcast | OriginProducer | OriginConsumer | Established; +type View = Broadcast | OriginProducer | OriginConsumer | Route | Established; const views = new WeakMap(); /** Register the package-private view for a handle. */ @@ -167,6 +175,7 @@ export function wireOf(handle: broadcast.Producer): Produced; export function wireOf(handle: broadcast.Producer | broadcast.Consumer): Broadcast; export function wireOf(handle: origin.Producer): OriginProducer; export function wireOf(handle: origin.Consumer): OriginConsumer; +export function wireOf(handle: origin.Route): Route; export function wireOf(handle: import("./connection/established.ts").Established): Established; /** Return the package-private view for a handle. */ export function wireOf(handle: object): View { diff --git a/js/publish/MIGRATE.md b/js/publish/MIGRATE.md index bbe065a4282..b3ec5506455 100644 --- a/js/publish/MIGRATE.md +++ b/js/publish/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Muting suppresses audio instead of disabling the rendition ([#7942](https://github.com/moq-dev/moq/pull/7942)) + +`` no longer marks the audio rendition `enabled: false`: it sends nothing, the rendition stays enabled in the catalog, and listeners stay subscribed, so the next word reaches them at once. +Each muted span ends with a discontinuity marker, which players treat as intentional silence. +The same is available on `Audio.Encoder` as the new `muted` knob; keep `enabled: false` for a stop the rendition does not come back from soon. +A viewer that read `enabled: false` in the catalog as "muted" no longer sees one; publish that state in your own catalog section or data track. + ### The capture graph renders to no output device ([#7881](https://github.com/moq-dev/moq/pull/7881)) `Audio.Capture`'s and `Audio.Encoder`'s `root` now sit in an `AudioContext` built with `sinkId: { type: "none" }`, which shortens the bursts capture hands the encoder. diff --git a/js/publish/api/index.api.md b/js/publish/api/index.api.md index 9ecb9d909da..c1005dd60ac 100644 --- a/js/publish/api/index.api.md +++ b/js/publish/api/index.api.md @@ -42,6 +42,7 @@ export namespace Audio { codec: Signals.Signal; groupDuration: Signals.Signal; readonly in: Signals.Readonlys; + muted: Signals.Signal; readonly name: string; readonly out: Signals.Readonlys; volume: Signals.Signal; @@ -62,6 +63,7 @@ export namespace Audio { export type EncoderProps = Signals.Inputs & { codec?: Audio.Codec | Signals.Signal; groupDuration?: Clock.Milli | Signals.Signal; + muted?: boolean | Signals.Signal; volume?: number | Signals.Signal; }; export interface Format { diff --git a/js/publish/src/audio/backlog.ts b/js/publish/src/audio/backlog.ts index 6d59dfecf45..732747b8069 100644 --- a/js/publish/src/audio/backlog.ts +++ b/js/publish/src/audio/backlog.ts @@ -29,9 +29,17 @@ export class Backlog { while (this.#samples > this.#limit) this.#evict(); } - /** Drop expired frames even when capture has stopped delivering samples. */ - expire(now: Micro): void { - while (this.deadline !== undefined && this.deadline <= now) this.#evict(); + /** + * Drop expired frames even when capture has stopped delivering samples. A `budget` shorter than + * the retention also drops what a subscriber that far behind the live edge would find stale. + */ + expire(now: Micro, budget: Micro = this.#retention): void { + const age = Math.min(budget, this.#retention); + for (;;) { + const first = this.#frames[this.#head]; + if (!first || first.end + age > now) return; + this.#evict(); + } } /** Whether frames were dropped unencoded since the last call, leaving a gap in the timeline. */ diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index f31e1f2ffe5..d4002c4d2e5 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -96,14 +96,15 @@ describe("toEncoderConfig", () => { }); }); -// Like Chrome's Opus encoder, it holds the newest chunks until later input pushes them out, and -// stamps each chunk from the first input's timestamp plus the audio encoded since, so a jump in -// input timestamps never reaches the output. -class LaggingAudioEncoder { - static instances: LaggingAudioEncoder[] = []; +// Like Chrome's Opus encoder, it emits one chunk per input and stamps each from the first input's +// timestamp plus the audio encoded since, so a jump in input timestamps never reaches the output. +// `LAG` holds that many of the newest chunks until later input pushes them out, a slower codec than +// any browser measured, for the tests that need one. +class FakeAudioEncoder { + static instances: FakeAudioEncoder[] = []; static probes: AudioEncoderConfig[] = []; static description: ((config: AudioEncoderConfig) => Uint8Array) | undefined; - static readonly LAG = 2; + static LAG = 0; // Called on configure; the encoder publishes its pipeline synchronously right after. static onConfigure: ((config: AudioEncoderConfig) => void) | undefined; @@ -122,7 +123,7 @@ class LaggingAudioEncoder { constructor(init: AudioEncoderInit) { this.#output = init.output; this.#error = init.error; - LaggingAudioEncoder.instances.push(this); + FakeAudioEncoder.instances.push(this); } fail(error: Error): void { @@ -136,7 +137,7 @@ class LaggingAudioEncoder { this.#fresh = true; queueMicrotask(() => { this.#fresh = false; - if (!this.#probe) LaggingAudioEncoder.onConfigure?.(config); + if (!this.#probe) FakeAudioEncoder.onConfigure?.(config); }); } @@ -145,7 +146,7 @@ class LaggingAudioEncoder { this.#base ??= data.timestamp; this.#held.push({ timestamp: this.#base + this.#encoded, duration }); this.#encoded += duration; - while (this.#held.length > LaggingAudioEncoder.LAG) { + while (this.#held.length > FakeAudioEncoder.LAG) { this.#emit(this.#held.shift() as { timestamp: number; duration: number }); } } @@ -153,7 +154,7 @@ class LaggingAudioEncoder { #emit({ timestamp, duration }: { timestamp: number; duration: number }): void { const chunk = { type: "key", timestamp, duration, byteLength: 1, copyTo: (dest: Uint8Array) => dest.set([1]) }; const config = this.#config; - const description = config && LaggingAudioEncoder.description?.(config); + const description = config && FakeAudioEncoder.description?.(config); const metadata = config && description ? { @@ -171,9 +172,9 @@ class LaggingAudioEncoder { async flush(): Promise { if (this.#fresh) { this.#probe = true; - if (this.#config) LaggingAudioEncoder.probes.push(structuredClone(this.#config)); - const index = LaggingAudioEncoder.instances.indexOf(this); - if (index !== -1) LaggingAudioEncoder.instances.splice(index, 1); + if (this.#config) FakeAudioEncoder.probes.push(structuredClone(this.#config)); + const index = FakeAudioEncoder.instances.indexOf(this); + if (index !== -1) FakeAudioEncoder.instances.splice(index, 1); } for (const chunk of this.#held.splice(0)) this.#emit(chunk); } @@ -206,12 +207,13 @@ class FakeAudioData { } function installFakeWebCodecs() { - LaggingAudioEncoder.instances = []; - LaggingAudioEncoder.probes = []; - LaggingAudioEncoder.description = undefined; + FakeAudioEncoder.instances = []; + FakeAudioEncoder.probes = []; + FakeAudioEncoder.LAG = 0; + FakeAudioEncoder.description = undefined; const names = ["AudioEncoder", "AudioDecoder", "AudioData"] as const; const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); - const fakes = [LaggingAudioEncoder, class {}, FakeAudioData]; + const fakes = [FakeAudioEncoder, class {}, FakeAudioData]; names.forEach((name, i) => { Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: fakes[i] }); }); @@ -284,8 +286,8 @@ function fakeCapture(feed: Feed) { // Resolves on the next AudioEncoder configure; the encoder publishes its pipeline synchronously after. function configured(): Promise { return new Promise((resolve) => { - LaggingAudioEncoder.onConfigure = (config) => { - LaggingAudioEncoder.onConfigure = undefined; + FakeAudioEncoder.onConfigure = (config) => { + FakeAudioEncoder.onConfigure = undefined; resolve(config); }; }); @@ -371,6 +373,7 @@ async function setup(order = new Order(), codec?: Codec, groupDuration?: Milli) // not the encoder's gap-blind one, or they trail the next gap's marker and are dropped as pre-gap. test("an on-demand gap marks where submitted audio ends and drops the chunks held across it", async () => { using _webcodecs = installFakeWebCodecs(); + FakeAudioEncoder.LAG = 2; using env = await setup(); const { track, rendition, feed, written, writes } = env; @@ -407,8 +410,8 @@ test("an on-demand gap marks where submitted audio ends and drops the chunks hel // A pause with a subscriber attached breaks the timeline just as losing demand does: a subscriber // that stays across it, or joins during it, must not read the audio before it as live. The codec -// keeps its configuration, so the audio it still held is flushed out before the marker. -test("disabling with a subscriber attached flushes the held audio, then marks where it ends", async () => { +// keeps its configuration and is not flushed. +test("disabling with a subscriber attached marks where the submitted audio ends", async () => { using _webcodecs = installFakeWebCodecs(); using env = await setup(); const { enabled, feed, written } = env; @@ -421,14 +424,14 @@ test("disabling with a subscriber attached flushes the held audio, then marks wh await feed.drain(); }; - await push(4); // two written, two held + await push(4); enabled.set(false); await settle(); await push(2); // nothing publishing enabled.set(true); - await push(3); // the restarted codec, so one written and two held + await push(3); expect(written).toEqual([ [20_000, 1], @@ -437,14 +440,18 @@ test("disabling with a subscriber attached flushes the held audio, then marks wh [80_000, 1], [100_000, 0], [140_000, 1], + [160_000, 1], + [180_000, 1], ]); - expect(LaggingAudioEncoder.instances).toHaveLength(1); + expect(FakeAudioEncoder.instances).toHaveLength(1); }); -// The codec has to restart across a capture gap to re-base its output clock, but the audio it still -// held predates the gap: it is flushed out first, and the marker follows it. -test("a capture gap flushes the held audio, then marks where it ends", async () => { +// A codec that still holds chunks at a capture gap stamps them, and the input after the gap, as one +// contiguous run. Stamping from what was submitted keeps each on its capture clock, and the marker +// waits behind the held chunks rather than dropping them as pre-gap. +test("a capture gap marks where the audio before it ends, after the chunks the codec holds", async () => { using _webcodecs = installFakeWebCodecs(); + FakeAudioEncoder.LAG = 2; using _now = manualAt(0); using env = await setup(); const { feed, written } = env; @@ -457,7 +464,7 @@ test("a capture gap flushes the held audio, then marks where it ends", async () }; await push(20_000, 4); // two written, two held - await push(200_000, 4); // the restarted codec, so two written and two held + await push(200_000, 4); // the held two, the marker, then two more written and two held expect(written).toEqual([ [20_000, 1], @@ -468,13 +475,14 @@ test("a capture gap flushes the held audio, then marks where it ends", async () [200_000, 1], [220_000, 1], ]); - expect(LaggingAudioEncoder.instances).toHaveLength(1); + expect(FakeAudioEncoder.instances).toHaveLength(1); }); // A lost track already marked where its audio ends, so what the codec held for it must not open the // track that replaces it. test("a closed track's held audio stays off its replacement", async () => { using _webcodecs = installFakeWebCodecs(); + FakeAudioEncoder.LAG = 2; using _now = manualAt(0); using env = await setup(); const { track, rendition, feed } = env; @@ -542,7 +550,7 @@ test("re-enabling with an unchanged capture reuses the probe", async () => { const { encoder, enabled } = env; const before = encoder.out.catalog.peek(); - expect(LaggingAudioEncoder.probes).toHaveLength(1); + expect(FakeAudioEncoder.probes).toHaveLength(1); enabled.set(false); await settle(); @@ -551,7 +559,7 @@ test("re-enabling with an unchanged capture reuses the probe", async () => { enabled.set(true); await settle(2); expect(encoder.out.catalog.peek()).toEqual(before); - expect(LaggingAudioEncoder.probes).toHaveLength(1); + expect(FakeAudioEncoder.probes).toHaveLength(1); }); test("re-enabling with a changed sample rate probes again", async () => { @@ -568,7 +576,7 @@ test("re-enabling with a changed sample rate probes again", async () => { for (let config = await configuring; config.sampleRate !== 24_000; config = await configuring) { configuring = configured(); } - expect(LaggingAudioEncoder.probes.map((probe) => probe.sampleRate)).toEqual([48_000, 24_000]); + expect(FakeAudioEncoder.probes.map((probe) => probe.sampleRate)).toEqual([48_000, 24_000]); expect(encoder.out.catalog.peek()?.enabled).toBe(false); enabled.set(true); @@ -577,6 +585,71 @@ test("re-enabling with a changed sample rate probes again", async () => { expect(encoder.out.catalog.peek()?.enabled).toBeUndefined(); }); +// Push-to-talk: a mute sends nothing and ends the utterance with a marker, while the rendition stays +// enabled and its track stays the same, so a listener hears the next press with no catalog update. +test("muting sends nothing and marks where the audio ends, keeping the rendition enabled", async () => { + using _webcodecs = installFakeWebCodecs(); + using env = await setup(); + const { encoder, feed, written, rendition } = env; + + const push = async (start: number, count: number) => { + for (let i = 0; i < count; i++) { + await feed.push({ timestamp: Micro(start + i * 20_000), channels: [new Float32Array(960)] }); + } + await feed.drain(); + }; + + const before = encoder.out.catalog.peek(); + expect(before?.enabled).toBeUndefined(); + await push(20_000, 2); + + encoder.muted.set(true); + await settle(); + expect(encoder.out.active.peek()).toBe(false); + await push(60_000, 3); // sent nowhere + expect(rendition.config.peek()).toEqual(before as never); + + encoder.muted.set(false); + await settle(); + expect(encoder.out.active.peek()).toBe(true); + await push(120_000, 2); + + expect(written).toEqual([ + [20_000, 1], + [40_000, 1], + [60_000, 0], + [120_000, 1], + [140_000, 1], + ]); + expect(rendition.config.peek()).toEqual(before as never); + expect(FakeAudioEncoder.instances).toHaveLength(1); + expect(FakeAudioEncoder.probes).toHaveLength(1); +}); + +// With `capture="release"` a mute closes the microphone, taking its format away. The rendition stays +// advertised, still enabled, through the mute and while the reopened microphone resolves. +test("a muted rendition keeps its config enabled while its capture is released", async () => { + using _webcodecs = installFakeWebCodecs(); + using env = await setup(); + const { encoder, capture } = env; + + const before = encoder.out.catalog.peek(); + encoder.muted.set(true); + const format = capture.out.format.peek(); + capture.out.format.set(undefined as never); + await settle(); + expect(encoder.out.catalog.peek()).toEqual(before); + + encoder.muted.set(false); + await settle(); + expect(encoder.out.catalog.peek()).toEqual(before); + + capture.out.format.set(format); + await settle(10); + expect(encoder.out.catalog.peek()).toEqual(before); + expect(FakeAudioEncoder.probes).toHaveLength(1); +}); + // Chrome stamps encoder output from its first input, ignoring later jumps, so audio resumed after a // long mute must still carry its capture timestamps rather than trail them by the mute. test("audio resumed after a multi-second mute carries its capture timestamps", async () => { @@ -591,7 +664,7 @@ test("audio resumed after a multi-second mute carries its capture timestamps", a await feed.drain(); }; - await push(20_000, 4); // two written, two held + await push(20_000, 4); enabled.set(false); await settle(); @@ -599,7 +672,7 @@ test("audio resumed after a multi-second mute carries its capture timestamps", a // Capture keeps running through a five-second mute. await push(100_000, 250); enabled.set(true); - await push(5_100_000, 4); // two written, two held + await push(5_100_000, 4); expect(written).toEqual([ [20_000, 1], @@ -609,8 +682,10 @@ test("audio resumed after a multi-second mute carries its capture timestamps", a [100_000, 0], [5_100_000, 1], [5_120_000, 1], + [5_140_000, 1], + [5_160_000, 1], ]); - expect(LaggingAudioEncoder.probes).toHaveLength(1); + expect(FakeAudioEncoder.probes).toHaveLength(1); }); // Closing tears down the subscription and the pipeline, which both end the epoch; cleanups run @@ -623,21 +698,23 @@ test("closing with a subscriber attached marks the end once before the track clo for (let i = 0; i < 4; i++) { await feed.push({ timestamp: Micro(20_000 + i * 20_000), channels: [new Float32Array(960)] }); } - await feed.drain(); // two written, two held + await feed.drain(); encoder.close(); expect(written).toEqual([ [20_000, 1], [40_000, 1], + [60_000, 1], + [80_000, 1], [100_000, 0], ]); expect(track.closed.peek()).toBeDefined(); }); -// A push that completes several frames is still one continuous stream, so it must not restart the -// encoder and drop the chunks it holds. -test("a push completing several frames keeps the encoder running", async () => { +// A push that completes several frames is still one continuous stream, so it must not break the +// timeline between them. +test("a push completing several frames stays one stream", async () => { using _webcodecs = installFakeWebCodecs(); using env = await setup(); const { feed, written } = env; @@ -653,6 +730,8 @@ test("a push completing several frames keeps the encoder running", async () => { [38_700, 1], [58_700, 1], [78_700, 1], + [98_700, 1], + [118_700, 1], ]); }); @@ -671,7 +750,7 @@ test("a group duration packs frames until the minimum and restarts after a break await feed.drain(); }; - await push(7); // five written, two held + await push(5); expect(groups).toEqual([5]); expect(appended[0].closed.peek()).toBeUndefined(); await push(2); // the next frame opens a new group and closes the first @@ -681,15 +760,16 @@ test("a group duration packs frames until the minimum and restarts after a break enabled.set(false); await settle(); + await push(2); // nothing publishing enabled.set(true); - await push(4); // the restarted codec, so two written and two held + await push(2); expect(written.map(([timestamp]) => timestamp)).toEqual([ - 20_000, 40_000, 60_000, 80_000, 100_000, 120_000, 140_000, 160_000, 180_000, 200_000, 200_000, 220_000, + 20_000, 40_000, 60_000, 80_000, 100_000, 120_000, 140_000, 160_000, 200_000, 220_000, ]); - // 120ms opens the next group, closing the five frames at 20-100ms. The break flushes 160-180ms - // into that group and ends it early, so resumed frames start a fresh group after the marker. - expect(groups).toEqual([5, 4, 1, 2]); + // 120ms opens the next group, closing the five frames at 20-100ms. The break ends that group + // early, so resumed frames start a fresh group after the marker. + expect(groups).toEqual([5, 2, 1, 2]); }); test("default audio groups carry twenty milliseconds", async () => { @@ -705,7 +785,7 @@ test("default audio groups carry twenty milliseconds", async () => { { mime: "opus", frameDuration: Milli(frameMs) }, groupMs === undefined ? undefined : Milli(groupMs), ); - for (let index = 0; index < packets * 2 + LaggingAudioEncoder.LAG; index++) { + for (let index = 0; index < packets * 2 + FakeAudioEncoder.LAG; index++) { await env.feed.push({ timestamp: Micro(20_000 + index * frameMs * 1000), channels: [new Float32Array(frameMs * 48)], @@ -750,6 +830,8 @@ test("the catalog bitrate only rises", async () => { // this one trailing it, which the catalog advertises as `delay` once a second of it has been measured. test("a rendition written behind another advertises delay", async () => { using _webcodecs = installFakeWebCodecs(); + // Each push writes the frame captured two before it, so the sibling stays ahead. + FakeAudioEncoder.LAG = 2; const clock = manualAt(200); try { @@ -760,8 +842,7 @@ test("a rendition written behind another advertises delay", async () => { expect(encoder.out.catalog.peek()?.delay).toBeUndefined(); // A sibling that stays 100ms ahead of the frames written, with a clock origin that keeps - // timestamps nonnegative. The encoder holds two frames, so each push writes the frame captured - // two before it. + // timestamps nonnegative. const sibling = new Estimator(); const start = Nano.toMicro(Nano(now())) - 100_000; const push = async (index: number) => { @@ -853,7 +934,7 @@ test("a failed audio encoder keeps its last catalog config disabled", async () = using _now = manualAt(0); using _error = spyOn(console, "error").mockImplementation(() => {}); using env = await setup(); - const live = LaggingAudioEncoder.instances.at(-1); + const live = FakeAudioEncoder.instances.at(-1); if (!live) throw new Error("expected live audio encoder"); const failure = new Error("audio encoder failed"); live.fail(failure); @@ -862,7 +943,7 @@ test("a failed audio encoder keeps its last catalog config disabled", async () = expect(env.encoder.out.error.peek()).toBe(failure); expect(env.encoder.out.catalog.peek()).toMatchObject({ codec: "opus", enabled: false }); expect(env.encoder.out.active.peek()).toBe(false); - expect(LaggingAudioEncoder.instances).toHaveLength(1); + expect(FakeAudioEncoder.instances).toHaveLength(1); }); test("audio failure stays local through demand changes and capture format re-arms a fresh track", async () => { @@ -910,11 +991,11 @@ test("audio failure stays local through demand changes and capture format re-arm const siblingTrack = sibling.track.peek(); if (!siblingTrack) throw new Error("expected demanded sibling track"); expect(siblingTrack.appendGroup().sequence).toBe(0n); - await push(0, 1); - expect((await subscriber.largest())?.group).toBe(0n); + await push(0, 1); // a group per frame + expect((await subscriber.largest())?.group).toBe(2n); catalog = new Json.Snapshot.Consumer({ track: net.track(Broadcast.CATALOG_TRACK) }); expect((await catalog.latest())?.value.audio?.renditions.audio?.enabled).not.toBe(false); - const live = LaggingAudioEncoder.instances.at(-1); + const live = FakeAudioEncoder.instances.at(-1); if (!live) throw new Error("expected live audio encoder"); const failure = new Error("audio codec failed"); live.fail(failure); @@ -923,7 +1004,7 @@ test("audio failure stays local through demand changes and capture format re-arm expect(encoder.out.error.peek()).toBe(failure); expect((await catalog.latest())?.value.audio?.renditions.audio?.enabled).toBe(false); expect(encoder.out.active.peek()).toBe(false); - const instances = LaggingAudioEncoder.instances.length; + const instances = FakeAudioEncoder.instances.length; subscriber.close(); subscriber = net.track("audio").subscribe(); broadcast.audio("audio/new"); @@ -935,7 +1016,7 @@ test("audio failure stays local through demand changes and capture format re-arm expect(subscriber.closed.peek()).toBeDefined(); expect(encoder.out.error.peek()).toBe(failure); expect(encoder.out.catalog.peek()?.enabled).toBe(false); - expect(LaggingAudioEncoder.instances).toHaveLength(instances); + expect(FakeAudioEncoder.instances).toHaveLength(instances); expect(broadcast.net.peek()).toBe(net); expect(announced?.active.peek()?.instance).toBe(epoch); expect(siblingTrack.appendGroup().sequence).toBe(1n); @@ -953,7 +1034,7 @@ test("audio failure stays local through demand changes and capture format re-arm recovered = net.track(config.track).subscribe(); await settle(); await push(3, 2); - expect((await recovered.largest())?.group).toBe(0n); + expect((await recovered.largest())?.group).toBe(2n); expect(encoder.out.active.peek()).toBe(true); expect(recovered.closed.peek()).toBeUndefined(); expect(broadcast.net.peek()).toBe(net); @@ -983,7 +1064,7 @@ test("an audio failure without demand still recovers under a fresh track name", await settle(); const net = broadcast.net.peek(); if (!net) throw new Error("expected network broadcast"); - const live = LaggingAudioEncoder.instances.at(-1); + const live = FakeAudioEncoder.instances.at(-1); if (!live) throw new Error("expected audio encoder without demand"); const failure = new Error("idle audio encoder failed"); live.fail(failure); @@ -1028,7 +1109,7 @@ test.each(["failure first", "config first"])( await settle(); const net = broadcast.net.peek(); if (!net) throw new Error("expected network broadcast"); - const live = LaggingAudioEncoder.instances.at(-1); + const live = FakeAudioEncoder.instances.at(-1); if (!live) throw new Error("expected idle audio encoder"); const reconfiguring = configured(); if (order === "config first") encoder.codec.set({ mime: "opus", bitrate: 64_000 }); @@ -1084,7 +1165,7 @@ test.each([undefined, false, true])( }; await push(4); - expect(encoder.out.stats.peek().frames).toBe(onDemand === false ? 2 : 0); + expect(encoder.out.stats.peek().frames).toBe(onDemand === false ? 4 : 0); late = net.track("audio").subscribe({ delay: { max: Milli(10_000) } }); await settle(); expect(encoder.out.active.peek()).toBe(true); @@ -1155,7 +1236,7 @@ test("a backlog encode refusal aborts its rendition when demand arrives", async await settle(); await feed.push({ timestamp: Micro(0), channels: [new Float32Array(960)] }); await feed.drain(); - const live = LaggingAudioEncoder.instances.at(-1); + const live = FakeAudioEncoder.instances.at(-1); if (!live) throw new Error("expected configured encoder"); const failure = new Error("codec refused buffered input"); using _encode = spyOn(live, "encode").mockImplementation(() => { @@ -1163,7 +1244,8 @@ test("a backlog encode refusal aborts its rendition when demand arrives", async }); const net = broadcast.net.peek(); if (!net) throw new Error("expected network broadcast"); - subscriber = net.track("audio").subscribe(); + // A live-edge budget would leave the retained frame unencoded. + subscriber = net.track("audio").subscribe({ delay: { max: null } }); await settle(); expect(encoder.out.error.peek()).toBe(failure); expect(subscriber.closed.peek()).toBe(failure); @@ -1203,13 +1285,14 @@ test("default audio replays the last second after three seconds without a subscr if (!net) throw new Error("expected network broadcast"); late = net.track("audio").subscribe({ delay: { max: Milli(1000) } }); await settle(); - expect(encoder.out.stats.peek().frames).toBe(50 - LaggingAudioEncoder.LAG); + expect(encoder.out.stats.peek().frames).toBe(50); for (let index = 150; index < 153; index++) { await feed.push({ timestamp: Micro(index * 20_000), channels: [new Float32Array(960)] }); } await feed.drain(); + // The subscription's one second reaches back from the newest frame, 152, to 102. const reader = late.ordered(); - for (let index = 100; index < 151; index++) { + for (let index = 102; index < 153; index++) { const group = await reader.nextGroup(); if (!group) throw new Error("expected buffered group"); const frame = await group.readFrame(); @@ -1225,6 +1308,87 @@ test("default audio replays the last second after three seconds without a subscr } }); +// Replayed groups reach the track as fresh arrivals, so only the encoder can tell they are old: a +// live viewer rejoining after seconds idle would otherwise wade through all of them first. +test("demand replays only the retained audio its max delay still wants", async () => { + using _webcodecs = installFakeWebCodecs(); + const clock = new Manual(); + const installed = clock.install(); + const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("rejoin.hang") }); + const feed = new Feed(); + const configuring = configured(); + const encoder = new Encoder("audio", { broadcast, capture: fakeCapture(feed) as never }); + let late: Moq.Track.Subscriber | undefined; + try { + await configuring; + await settle(); + for (let index = 0; index < 150; index++) { + await feed.push({ timestamp: Micro(index * 20_000), channels: [new Float32Array(960)] }); + await feed.drain(); + await clock.advance(Nano.fromMilli(Milli(20))); + } + const net = broadcast.net.peek(); + if (!net) throw new Error("expected network broadcast"); + // At 3000 ms, a 130 ms budget still wants the frames ending after 2870 ms: 143 onward. + late = net.track("audio").subscribe({ delay: { max: Milli(130) } }); + await settle(); + expect(encoder.out.stats.peek().frames).toBe(7 - FakeAudioEncoder.LAG); + await feed.push({ timestamp: Micro(150 * 20_000), channels: [new Float32Array(960)] }); + await feed.drain(); + const group = await late.ordered().nextGroup(); + const frame = await group?.readFrame(); + if (!frame) throw new Error("expected a replayed frame"); + expect(Moq.Varint.decode(frame.payload)[0]).toBe(143 * 20_000); + } finally { + late?.close(); + encoder.close(); + broadcast.close(); + installed.close(); + } +}); + +// A fetch's own budget judges only the group it asks for, so it must not narrow what the encoder +// replays for the live subscribers that come later. +test("a fetch alone replays the whole retention", async () => { + using _webcodecs = installFakeWebCodecs(); + const clock = new Manual(); + const installed = clock.install(); + const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("fetch.hang") }); + const feed = new Feed(); + const configuring = configured(); + const encoder = new Encoder("audio", { broadcast, capture: fakeCapture(feed) as never }); + let fetched: Moq.Group.Consumer | undefined; + try { + await configuring; + await settle(); + const net = broadcast.net.peek(); + if (!net) throw new Error("expected network broadcast"); + // A first viewer leaves group 0 in the cache for the fetch to find. + const first = net.track("audio").subscribe({ delay: { max: null } }); + await settle(); + await feed.push({ timestamp: Micro(0), channels: [new Float32Array(960)] }); + await feed.drain(); + first.close(); + await settle(); + const encoded = encoder.out.stats.peek().frames; + for (let index = 1; index <= 150; index++) { + await clock.advance(Nano.fromMilli(Milli(20))); + await feed.push({ timestamp: Micro(index * 20_000), channels: [new Float32Array(960)] }); + await feed.drain(); + } + expect(encoder.out.stats.peek().frames).toBe(encoded); + // Held open, the fetched group keeps the fetch's demand alive while the encoder reacts. + fetched = await net.track("audio").fetchGroup(0n); + await settle(); + expect(encoder.out.stats.peek().frames).toBe(encoded + 150); + } finally { + fetched?.close(); + encoder.close(); + broadcast.close(); + installed.close(); + } +}); + // Replay catches up behind the live edge another rendition already reached; advertising that as // `delay` would buffer playback by the retention for the rest of the broadcast. test("replayed raw audio raises no catalog delay", async () => { @@ -1324,7 +1488,7 @@ test("expired raw audio ends the epoch before the replay resumes", async () => { test("Opus description is probed with the exact live config before advertising", async () => { using _codecs = installFakeWebCodecs(); - LaggingAudioEncoder.description = (config) => new TextEncoder().encode(JSON.stringify(config)); + FakeAudioEncoder.description = (config) => new TextEncoder().encode(JSON.stringify(config)); using env = await setup(new Order(), { mime: "opus", frameDuration: Milli(2.5), @@ -1334,14 +1498,14 @@ test("Opus description is probed with the exact live config before advertising", bitrate: 64000, }); expect(env.encoder.out.catalog.peek()?.description).toBe(Buffer.from(JSON.stringify(env.config)).toString("hex")); - expect(LaggingAudioEncoder.probes).toEqual([env.config]); - expect(LaggingAudioEncoder.instances).toHaveLength(1); + expect(FakeAudioEncoder.probes).toEqual([env.config]); + expect(FakeAudioEncoder.instances).toHaveLength(1); expect(env.written).toHaveLength(0); const configuring = configured(); env.capture.in.source.set({ track: {}, kind: "voice" } as never); const voice = await configuring; expect(voice.opus).toMatchObject({ application: "voip", signal: "voice" }); - expect(LaggingAudioEncoder.probes.at(-1)).toEqual(voice); + expect(FakeAudioEncoder.probes.at(-1)).toEqual(voice); expect(env.encoder.out.catalog.peek()?.description).toBe(Buffer.from(JSON.stringify(voice)).toString("hex")); }); @@ -1385,7 +1549,7 @@ test.each([false, true])("audio retained before a disable is encoded on demand ( expect(encoder.out.stats.peek().frames).toBe(0); return; } - // The codec is flushed after the history, since nothing follows it while disabled. + // The history is encoded and its marker follows at once, since nothing else is owed while disabled. expect(encoder.out.stats.peek().frames).toBe(4); const reader = late.ordered(); for (let index = 0; index < 4; index++) { @@ -1404,7 +1568,7 @@ test.each([false, true])("audio retained before a disable is encoded on demand ( }); // Re-enabled before anyone wanted it, the retained audio stays ahead of the break: all of it is -// encoded and flushed before the marker, then the resumed audio starts a fresh epoch. +// encoded before the marker, then the resumed audio starts a fresh epoch. test("audio retained across a disable and re-enable is served before the break", async () => { using _webcodecs = installFakeWebCodecs(); const clock = new Manual(); @@ -1443,8 +1607,8 @@ test("audio retained across a disable and re-enable is served before the break", if (!net) throw new Error("expected network broadcast"); late = net.track("audio").subscribe({ delay: { max: Milli(1000) } }); await settle(20); - // The four retained frames are flushed before the marker, then the restarted codec holds two. - expect(encoder.out.stats.peek().frames).toBe(5); + // The four retained frames, then the marker, then the three resumed. + expect(encoder.out.stats.peek().frames).toBe(7); expect(discontinuity.mock.calls.map(([end]) => end)).toEqual([Micro(80_000)]); const reader = late.ordered(); const read: [number, number][] = []; @@ -1473,8 +1637,8 @@ test("audio retained across a disable and re-enable is served before the break", } }); -// A replay submits the whole backlog at once, so restarting the codec at a gap inside it would drop -// every frame of that drain still queued in a real codec. The gap flushes them out first instead. +// A replay submits the whole backlog at once, so a gap inside it must break the timeline without +// dropping any frame of that drain still owed by the codec. test("a capture gap inside replayed audio keeps every frame on both sides of it", async () => { using _webcodecs = installFakeWebCodecs(); const clock = new Manual(); @@ -1507,8 +1671,8 @@ test("a capture gap inside replayed audio keeps every frame on both sides of it" if (!net) throw new Error("expected network broadcast"); late = net.track("audio").subscribe({ delay: { max: Milli(1000) } }); await settle(20); - // Every frame before the gap, then the restarted codec holds two of the four after it. - expect(encoder.out.stats.peek().frames).toBe(6); + // Every frame on both sides of the gap. + expect(encoder.out.stats.peek().frames).toBe(8); expect(discontinuity.mock.calls.map(([end]) => end)).toEqual([Micro(80_000)]); const reader = late.ordered(); const read: [number, number][] = []; @@ -1538,8 +1702,8 @@ test("a capture gap inside replayed audio keeps every frame on both sides of it" } }); -// A zero max age retains nothing, not even the frame waiting behind a gap's flush, so the gap must not -// depend on the backlog to be noticed, or every frame after it would wait forever. +// A zero max age retains nothing, so a gap must not depend on the backlog to be noticed, or every +// frame after it would wait forever. test("a capture gap without retention resumes the audio after it", async () => { using _webcodecs = installFakeWebCodecs(); const clock = new Manual(); @@ -1570,12 +1734,11 @@ test("a capture gap without retention resumes the audio after it", async () => { subscriber = net.track("audio").subscribe(); await settle(); - await push(0, 4); // two written, two held - // The first frame after the gap waits behind the flush, where nothing is retained. + await push(0, 4); await push(200_000, 4); expect(encode.mock.calls.map(([, timestamp]) => timestamp)).toEqual( - [0, 20_000, 40_000, 60_000, 220_000].map((t) => Micro(t)), + [0, 20_000, 40_000, 60_000, 200_000, 220_000, 240_000, 260_000].map((t) => Micro(t)), ); expect(discontinuity.mock.calls.map(([end]) => end)).toEqual([Micro(80_000)]); } finally { @@ -1588,7 +1751,7 @@ test("a capture gap without retention resumes the audio after it", async () => { } }); -// Two disables before anyone listens leave two breaks in the backlog: each flushes the audio before it. +// Two disables before anyone listens leave two breaks in the backlog: each follows the audio before it. test("every disable retained before demand gets its own break", async () => { using _webcodecs = installFakeWebCodecs(); const clock = new Manual(); @@ -1676,10 +1839,9 @@ test("a break after expired history keeps the resumed audio flowing", async () = if (!net) throw new Error("expected network broadcast"); late = net.track("audio").subscribe({ delay: { max: Milli(1000) } }); await settle(20); - // The codec holds two of the three resumed frames. - expect(encoder.out.stats.peek().frames).toBe(1); - await push(2_060_000, 2); expect(encoder.out.stats.peek().frames).toBe(3); + await push(2_060_000, 2); + expect(encoder.out.stats.peek().frames).toBe(5); } finally { late?.close(); encoder.close(); @@ -1689,8 +1851,8 @@ test("a break after expired history keeps the resumed audio flowing", async () = }); // Demand that arrives after a re-enable but before any resumed frame drains the retained audio first, -// so the break still flushes it rather than cutting it off with the codec mid-flight. -test("demand between a re-enable and the resumed audio still flushes the break", async () => { +// so the break follows it rather than cutting it off with the codec mid-flight. +test("demand between a re-enable and the resumed audio still breaks after the retained audio", async () => { using _webcodecs = installFakeWebCodecs(); const clock = new Manual(); const installed = clock.install(); @@ -1729,8 +1891,7 @@ test("demand between a re-enable and the resumed audio still flushes the break", expect(discontinuity.mock.calls.map(([end]) => end)).toEqual([Micro(80_000)]); await push(200_000, 3); - // The restarted codec holds two of the three resumed frames. - expect(encoder.out.stats.peek().frames).toBe(5); + expect(encoder.out.stats.peek().frames).toBe(7); expect(discontinuity).toHaveBeenCalledTimes(1); } finally { discontinuity.mockRestore(); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index c1203ad7bd7..6f90cbc734a 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -91,11 +91,12 @@ export interface Stats { /** Signals the encoder reads. */ export type EncoderInput = { - // Whether to encode this rendition. Defaults to true. When false it stops taking capture, ending the + // Whether to serve this rendition. Defaults to true. When false it stops taking capture, ending the // epoch, but stays in the catalog with `enabled: false` and its last config, and stays registered so a // subscriber still gets an idle track. Audio retained before the disable stays too: a subscriber // still gets it encoded, and it expires on the broadcast's `maxAge`. The codec stays configured, so - // re-enabling resumes at once. + // re-enabling resumes at once. For a pause the rendition comes back from, such as push-to-talk, use + // `muted` instead, which costs subscribers no catalog update. enabled: Getter; // The broadcast to register the rendition on. Undefined resolves the config but has nowhere to publish. @@ -116,6 +117,9 @@ export type EncoderProps = Inputs & { // User tuning knobs. Seed a value or wire a Signal; also live-editable via the matching field. volume?: number | Signal; + // Send no audio while true, keeping the rendition enabled and its subscribers attached. Defaults to false. + muted?: boolean | Signal; + // Codec selection plus encoder settings. Defaults to "opus". codec?: Codec | Signal; @@ -135,9 +139,9 @@ export type EncoderOutput = { // browser supports `sinkId`, so nothing connected to its destination is heard. Undefined for a // source that isn't a track. root: Signal; - // True while encoding capture: enabled with a captured format and a track, which also takes a - // subscriber unless the broadcast's `onDemand` is false. Retained audio encoded for a subscriber - // while disabled does not count. + // True while encoding capture: enabled and unmuted with a captured format and a track, which also + // takes a subscriber unless the broadcast's `onDemand` is false. Retained audio encoded for a + // subscriber while paused does not count. active: Signal; // Cumulative output totals (frames, bytes) measured while serving, and the target bitrate. stats: Signal; @@ -168,6 +172,12 @@ export class Encoder { /** Linear gain applied before encoding, where 1 is unity. Each change ramps over 50 ms, so a mute is silent once it passes. */ volume: Signal; + /** + * Whether to send nothing, as push-to-talk does between presses. Unlike `enabled`, the rendition + * stays enabled in the catalog and its subscribers stay attached, so the next audio reaches them + * at once; each muted span is a declared end, which a player fills as intentional silence. + */ + muted: Signal; /** The live-editable codec selection plus its encoder settings. */ codec: Signal; /** @@ -210,6 +220,10 @@ export class Encoder { // discontinuity and re-anchors on. #pipeline: Pipeline | undefined; #encoding = false; + // The demand's max delay, past which retained audio is stale to every subscriber, or undefined + // for the retention alone. Replayed groups land as fresh arrivals, so only this cut keeps a + // rejoining live viewer from receiving all the retained audio before the live edge. + #budget: Micro | undefined; // The rendition's track and producer, kept across subscriber changes. // `start` is the open group's first timestamp, undefined until a frame opens one. @@ -261,6 +275,9 @@ export class Encoder { // config as disabled until the re-enabled capture resolves a new one. A capture that never reopens // leaves it disabled, which is accurate: no frames are coming. #paused = false; + // Whether it was muted since a config last resolved, so it keeps advertising its last config, + // enabled, while a released capture is closed and until the reopened one resolves. + #muted = false; constructor(name: string, props?: EncoderProps) { // `source` moved to Audio.Capture, which renditions share. TypeScript catches this, but a @@ -278,6 +295,7 @@ export class Encoder { bandwidth: getter(props?.bandwidth), }; this.volume = Signal.from(props?.volume ?? 1); + this.muted = Signal.from(props?.muted ?? false); this.codec = Signal.from(props?.codec ?? "opus"); this.groupDuration = Signal.from(props?.groupDuration ?? Milli(20)); @@ -463,7 +481,7 @@ export class Encoder { }); effect.run((effect) => { - const enabled = effect.get(this.in.enabled); + const enabled = effect.get(this.in.enabled) && !effect.get(this.muted); const capture = effect.get(this.in.capture); const format = capture ? effect.get(capture.out.format) : undefined; const track = effect.get(rendition.track); @@ -473,7 +491,10 @@ export class Encoder { const onDemand = effect.get(broadcast.in.onDemand) ?? true; const demanded = !!track && effect.get(track.demand().wanted) !== undefined; - // Not gated on `enabled`: a disabled rendition takes no capture, but still encodes what it retained. + // Demand from fetches alone carries no subscription, so no budget beyond the retention. + const max = track && effect.get(track.subscription)?.delay?.max; + this.#budget = max === undefined || max === null ? undefined : Micro.fromMilli(max); + // Not gated on `enabled` or `muted`: a paused rendition takes no capture, but still encodes what it retained. this.#encoding = !!format && !!track && !fatal && (!onDemand || demanded); effect.set(this.#out.active, enabled && this.#encoding, false); try { @@ -490,7 +511,7 @@ export class Encoder { // encoder's share is honest. Wait for a bitrate so we never claim 0. The grant is ignored: // following it for Opus is out of scope. effect.run((effect) => { - const enabled = effect.get(this.in.enabled); + const enabled = effect.get(this.in.enabled) && !effect.get(this.muted); const track = effect.get(rendition.track); const allocator = effect.get(this.in.bandwidth); if (!enabled || !track || !allocator || effect.get(this.#out.error)) return; @@ -608,21 +629,23 @@ export class Encoder { effect.cleanup(() => this.#failures.update((n) => n - 1)); } - // Publish the probed config before the first frame reaches the track. A - // disabled rendition keeps its last config, since muting also releases the capture, and stays - // disabled after re-enabling until the reopened capture reports its format. + // Publish the probed config before the first frame reaches the track. A disabled rendition keeps its + // last config, since a disable may also release the capture, and stays disabled after re-enabling + // until the reopened capture reports its format. A muted one keeps it enabled throughout. #runCatalog(effect: Effect): void { const estimate = effect.get(this.#estimate); const enabled = effect.get(this.in.enabled) && !effect.get(this.#out.error); const slot = this.#current(effect); if (!enabled) this.#paused = true; + if (effect.get(this.muted)) this.#muted = true; const config = enabled ? effect.get(this.#config)?.catalog : undefined; if (!config) { - const last = this.#paused ? slot.last : undefined; - effect.set(this.#out.catalog, last && { ...last, ...estimate, enabled: false }); + const last = this.#paused || this.#muted ? slot.last : undefined; + effect.set(this.#out.catalog, last && { ...last, ...estimate, ...(this.#paused && { enabled: false }) }); return; } this.#paused = false; + this.#muted = false; // The catalog bitrate is the most this track has been configured for, so lowering the knob // retunes the encoder without lowering what the catalog advertises. @@ -646,15 +669,19 @@ export class Encoder { return opus; } - // End the epoch with a discontinuity marker (see Container.Legacy.Producer.discontinuity) where the - // submitted media ends, so a subscriber that stays across the break, or joins during it, doesn't - // read the audio before it as live. Called whenever the track goes away or the pipeline stops. - #cut(): void { - const end = this.#next; - this.#next = undefined; - const live = this.#live; - if (end === undefined || !live || live.track.closed.peek() !== undefined) return; + // End the epoch with a discontinuity marker (see Container.Legacy.Producer.discontinuity) at `end`, + // by default where the submitted media ends, so a subscriber that stays across the break, or joins + // during it, doesn't read the audio before it as live. A marker at or below the newest one is + // already covered by it. + #cut(end = this.#next): void { + if (end === undefined) return; + if (this.#next !== undefined && this.#next <= end) this.#next = undefined; + if (this.#floor !== undefined && end <= this.#floor) return; + // The epoch ends here even when no track is left to mark, so whatever the codec still owes for + // it stays off the next track. this.#floor = end; + const live = this.#live; + if (!live || live.track.closed.peek() !== undefined) return; live.producer.discontinuity(end); live.start = undefined; } @@ -692,11 +719,31 @@ export class Encoder { let resampler = createResampler(); let framer = createFramer(resolved, config.sampleRate); + // What the codec still owes, in submission order: each input's timestamp, or a break whose + // marker goes down once every chunk before it is out. Chunks are stamped from here, never + // by the codec: Chrome and Firefox stamp input after a gap as if it were contiguous. + // Every browser emits one chunk per input, in order, so a codec that holds a chunk only + // delays its stamp, and a break needs no flush. + const owed: ({ timestamp: Micro } | { cut: Micro })[] = []; + // Write the marker of every break the codec has caught up with. + const settle = () => { + for (let next = owed[0]; next && "cut" in next; next = owed[0]) { + owed.shift(); + this.#cut(next.cut); + } + }; + const encoder = new AudioEncoder({ output: (frame) => { if (frame.type !== "key") { throw new Error("only key frames are supported"); } + const input = owed.shift(); + if (!input || "cut" in input) { + this.#abort(new Error("the audio encoder emitted a chunk for no input")); + return; + } + const timestamp = input.timestamp; this.#out.stats.update((stats) => ({ ...stats, @@ -705,21 +752,20 @@ export class Encoder { })); const live = this.#live; - if (!live) return; - const timestamp = frame.timestamp as Micro; - if (this.#floor !== undefined && timestamp < this.#floor) return; - - // Every audio frame decodes on its own, so any of them can open a group: the - // first one at or past the minimum after the open group's start. Frames forward - // as they are written rather than waiting for the group to fill. A dropped - // group leaves a gap for the codec's PLC. - const keyframe = live.start === undefined || timestamp - live.start >= this.#groupDuration; - if (keyframe) live.start = timestamp; - live.producer.encode(frame, timestamp, keyframe); - const history = this.#history !== undefined && timestamp < this.#history; - if (!history && this.#estimator.write(frame.timestamp, order, keyframe)) { - this.#estimate.set({ ...this.#estimator.estimate }); + if (live && (this.#floor === undefined || timestamp >= this.#floor)) { + // Every audio frame decodes on its own, so any of them can open a group: the + // first one at or past the minimum after the open group's start. Frames forward + // as they are written rather than waiting for the group to fill. A dropped + // group leaves a gap for the codec's PLC. + const keyframe = live.start === undefined || timestamp - live.start >= this.#groupDuration; + if (keyframe) live.start = timestamp; + live.producer.encode(frame, timestamp, keyframe); + const history = this.#history !== undefined && timestamp < this.#history; + if (!history && this.#estimator.write(timestamp, order, keyframe)) { + this.#estimate.set({ ...this.#estimator.estimate }); + } } + settle(); }, error: (err) => { if (!aborted.aborted) this.#abort(err); @@ -738,15 +784,13 @@ export class Encoder { return; } - // Where the next frame starts if it continues the last one encoded. + // Where the next frame starts if it continues the last one submitted, unset after a break. let contiguous: Micro | undefined; - // Disabled: capture is dropped, while the retained audio stays reachable. + // Disabled or muted: capture is dropped, while the retained audio stays reachable. let paused = false; - // A disable left retained audio, so the timeline breaks after it rather than at once. + // A pause left retained audio, so the timeline breaks after it rather than at once. let breakPending = false; - // The codec is flushing before a break, so nothing new reaches it until the marker is down. - let flushing = false; const backlog = new Backlog(retention, config.sampleRate); const deadline = new Signal(undefined); @@ -763,18 +807,22 @@ export class Encoder { ); }); - // Whether a gap (a lost track, or a capture discontinuity) separates `data` from the last - // frame the codec took. Chrome stamps encoder output from the first input's timestamp - // plus the samples encoded since, ignoring any later jump, so across a gap the output - // would trail the capture clock by the gap, and the next gap's marker would then sit - // ahead of everything encoded after it. So a gap restarts the codec, which re-bases it. - // Timestamps round to whole microseconds, so a chunk's timestamp and the marker placed - // at the next frame's start agree exactly. - const gap = (data: AudioFrame) => contiguous !== undefined && Math.round(data.timestamp) !== contiguous; + // End the epoch where the submitted audio ends, once the codec has emitted all of it: a + // marker placed while chunks are still owed would drop them as pre-gap. + const cut = () => { + if (contiguous === undefined) return; + owed.push({ cut: contiguous }); + contiguous = undefined; + settle(); + }; - // Encode one frame, which must not follow a gap: the gap's flush and restart come first. + // Encode one frame, breaking the timeline first if it does not continue the last one. + // Timestamps round to whole microseconds, so a chunk's timestamp and the marker placed at + // the next frame's start agree exactly. const submit = (data: AudioFrame) => { const timestamp = Math.round(data.timestamp) as Micro; + if (contiguous !== undefined && timestamp !== contiguous) cut(); + const joinedLength = data.channels.reduce((total, channel) => total + channel.length, 0); const joined = new Float32Array(joinedLength); @@ -793,6 +841,7 @@ export class Encoder { transfer: [joined.buffer], }); + owed.push({ timestamp }); encoder.encode(frame); frame.close(); contiguous = Math.round( @@ -800,64 +849,29 @@ export class Encoder { ) as Micro; this.#next = contiguous; }; - // The next frame starts a new epoch, so it starts the codec afresh too. - const restart = () => { - encoder.reset(); - encoder.configure(encoderConfig); - contiguous = undefined; - }; - // End the epoch where the submitted audio ends, once the codec has emitted all of it: a - // marker placed while chunks are still in flight would drop them as pre-gap. - const cutAfterFlush = () => { - if (flushing) return; - // Nothing reached the codec since it last started, so nothing is in flight. - if (contiguous === undefined) return this.#cut(); - // The epoch already ended without a flush (a lost track, or expired history), so what - // the codec holds sits below that marker and has nowhere to go. - if (this.#next === undefined) return restart(); - flushing = true; - encoder - .flush() - .then(() => { - if (aborted.aborted) return; - flushing = false; - this.#cut(); - restart(); - if (this.#encoding) pipeline.drain(); - }) - .catch((error) => { - if (!aborted.aborted) this.#abort(error); - }); - }; const pipeline: Pipeline = { channelCount: config.numberOfChannels, drain: () => { - if (flushing) return; - backlog.expire(clock()); - // Expired history is a real gap, unlike a loss of demand: end the epoch where - // the encoded audio stops, so its last frame isn't stretched across the gap. - // What the codec still holds is older than the expired audio, so it is dropped. - if (backlog.takeEvicted()) this.#cut(); + backlog.expire(clock(), this.#budget); + // Expired history is a real gap, unlike a loss of demand: end the epoch where the + // encoded audio stops, so its last frame isn't stretched across the gap. + if (backlog.takeEvicted()) cut(); for (;;) { const frame = backlog.peek(); if (!frame) break; - // A disable or a gap broke the timeline here; the rest follows once the marker - // is down. A cut with nothing in flight lands at once. - if (backlog.takeCut() || gap(frame)) { - cutAfterFlush(); - if (flushing) break; - } + // A pause broke the timeline here. + if (backlog.takeCut()) cut(); backlog.shift(); submit(frame); this.#history = this.#next; } deadline.set(backlog.deadline); - // The audio retained before a disable is all encoded, so the break follows it, even - // when re-enabled since: resumed audio then waits behind the flush. + // The audio retained before a pause is all encoded, so the break follows it, even + // when resumed since. if (breakPending && backlog.empty) { breakPending = false; - cutAfterFlush(); + cut(); } }, push: (captured: AudioFrame) => { @@ -867,28 +881,28 @@ export class Encoder { const frames = framer.push(input); for (const [i, data] of frames.entries()) { if (!this.#live) continue; - const cut = breakPending; + const pending = breakPending; breakPending = false; - const live = this.#encoding && !flushing && backlog.empty; - // Started here rather than from the backlog, which may not keep the frame: a - // zero max age retains nothing. - if (live && (cut || gap(data))) cutAfterFlush(); - if (live && !flushing) { + if (this.#encoding && backlog.empty) { + if (pending) cut(); submit(data); continue; } - // It waits for a subscriber, or behind the flush before a break. + // It waits for a subscriber. Started here rather than from the backlog, which + // may not keep the frame: a zero max age retains nothing. const end = frames[i + 1]?.timestamp ?? framer.next; - backlog.push(data, end, clock(), cut && !live); + backlog.push(data, end, clock(), pending); } deadline.set(backlog.deadline); }, }; - // A disable stops taking capture but keeps the codec and retained audio, so a subscriber - // still gets that history and re-enabling resumes without reconfiguring. + // A disable or a mute stops taking capture but keeps the codec and retained audio, so a + // subscriber still gets that history and resuming needs no reconfigure. Neither flushes: + // the codec emits what it owes on its own, and a flush would send its lookahead tail at + // once, a frame ahead of its media. effect.run((toggle) => { - if (toggle.get(this.in.enabled)) { + if (toggle.get(this.in.enabled) && !toggle.get(this.muted)) { paused = false; return; } @@ -896,7 +910,7 @@ export class Encoder { // The capture resumes on a later timeline, so drop the partial frame and filter state. resampler = createResampler(); framer = createFramer(resolved, config.sampleRate); - if (backlog.empty) cutAfterFlush(); + if (backlog.empty) cut(); else breakPending = true; }); diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index a6a949f1a3e..4f9e0eb351b 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -390,7 +390,7 @@ test("serves no catalog until announced, so the first snapshot is the one at ann // Regression: a re-announce re-created the catalog track, restarting its group sequence under the // same instance, so a resumed viewer mistook the new catalog for the old one. Turning the announce off -// unannounces at once and keeps the broadcast for the next announce. +// withdraws the route at once and keeps it, broadcast and instance, for the next announce. test("a re-announce continues the catalog's group sequence", async () => { const announce = new Signal(true); const broadcast = new Broadcast({ @@ -407,6 +407,8 @@ test("a re-announce continues the catalog's group sequence", async () => { const announced = broadcast.in.origin.peek()?.follow(broadcast.in.name.peek()); const first = net.track(Broadcast.CATALOG_TRACK).subscribe().ordered(); expect((await first.nextGroup())?.sequence).toBe(0n); + const instance = announced?.active.peek()?.instance; + expect(instance).toBeDefined(); announce.set(false); await settle(); @@ -416,7 +418,9 @@ test("a re-announce continues the catalog's group sequence", async () => { announce.set(true); await settle(); - // The same track keeps serving, and its newest group is past the first. + // The follower continues the same instance, the same track keeps serving, and its newest group + // is past the first. + expect(announced?.active.peek()?.instance).toBe(instance); expect(broadcast.net.peek()).toBe(net); const group = await first.nextGroup(); expect(group?.sequence).toBeGreaterThan(0); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 0344e5004cf..da6d46ea14a 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -31,7 +31,7 @@ export type BroadcastInput = { // see or subscribe to it. The flip rather than a gate on creating it: tracks can be // populated while this is false, then announced once ready. The catalog is served from the // first announce, so its first snapshot is the one current at announce time. - // Turning it off unannounces the broadcast. + // Turning it off withdraws the route, and turning it back on continues the same stream. announce: Getter; // The broadcast name. @@ -445,13 +445,13 @@ export class Broadcast { ); } - // Creating into the origin outlives any single session: a reconnect re-announces the - // broadcast and new subscriptions land on the same producer. - const broadcastPublication = origin.createBroadcast(name); - const broadcast = broadcastPublication.broadcast; - // The publication mints one instance, kept across unannounce and announce, so a restart - // is a new stream rather than a resume into the old one. - effect.cleanup(() => broadcast.close()); + // Routing into the origin outlives any single session: a reconnect re-announces the + // broadcast and new subscriptions land on the same producer. Hidden until the catalog is + // served. The route mints one instance, kept across withdraw and announce, so toggling + // `announce` continues the same stream for viewers; a new route is a new stream. + const route = origin.route(name, { hidden: true }); + const broadcast = route.broadcast; + effect.cleanup(() => route.close()); // Expose it before serving so an application reacting to `net` can insert its own tracks. this.#net.set(broadcast); @@ -483,7 +483,7 @@ export class Broadcast { let serving = false; effect.run((inner) => { if (!inner.get(this.in.announce)) { - broadcastPublication.unannounce(); + route.withdraw(); return; } @@ -510,7 +510,7 @@ export class Broadcast { this.catalog.serve(track, effect); } - broadcastPublication.announce(); + route.announce(); }); // Static tracks fan out to every subscriber. The encoder-facing handle stays set while the diff --git a/js/publish/src/element.ts b/js/publish/src/element.ts index 98f97576abd..997339c5ec6 100644 --- a/js/publish/src/element.ts +++ b/js/publish/src/element.ts @@ -196,7 +196,8 @@ export default class MoqPublish extends HTMLElement { // The preview element, either a