diff --git a/.claude/skills/spawn-merge/SKILL.md b/.claude/skills/spawn-merge/SKILL.md index 6549ea57d3..94e4ede39b 100644 --- a/.claude/skills/spawn-merge/SKILL.md +++ b/.claude/skills/spawn-merge/SKILL.md @@ -8,7 +8,7 @@ Read the /merge, /takeover, and /close skills before starting. The goal is to evaluate the open PRs in the repository and decide which ones to merge. Each merge is performed in parallel by a sub-agent. -Start by listing all open PRs. +Start by listing all open PRs that are ready for review (skip drafts). An argument can be used to filter the PRs in scope. One at a time, for each PR, interactively prompt the user if we should /merge, skip, or /close. diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md index 80d7880e34..ce7afbe14b 100644 --- a/.claude/skills/spawn-quests/SKILL.md +++ b/.claude/skills/spawn-quests/SKILL.md @@ -18,6 +18,7 @@ Include a short summary and your recommendation. Spawn a background sub-agent for each /start-quest. Create a fresh worktree on the base `quest branch` prints, creating that line branch first if it is missing. +Agents share no writable files: each keeps its scratch files in its own worktree's `.scratch/`, and anything you hand every agent goes in its prompt, not a shared file. Limit the concurrency to at most N agents in parallel, where N is half the number of physical CPU cores. Monitor the sub-agents and report their final status, but do not monitor their PRs. diff --git a/.claude/skills/start-quest/SKILL.md b/.claude/skills/start-quest/SKILL.md index d6820efab3..4ec47379e5 100644 --- a/.claude/skills/start-quest/SKILL.md +++ b/.claude/skills/start-quest/SKILL.md @@ -5,12 +5,18 @@ description: Start work on a quest. Before you begin, read `quest/CLAUDE.md` completely. -Your goal is to implement the quest, or as much of it as possible, and create a PR. +Your goal is to implement the quest, or as much of it as possible, and create a draft PR. The argument is the quest to work on. If you are unsure on the best course of action, ask the user for direction. Confirm the quest is ready and unclaimed. Claim it as `quest/CLAUDE.md` describes: `quest branch` names the branch and its bases, missing line branches get a draft PR, and the quest branch gets an empty commit. + Implement the quest until it is complete, or some blocker is hit, then create a PR against the base. -Summarize the notable changes for the user. +Keep scratch files (PR body, logs, notes) in the worktree's gitignored `.scratch/`. +Never write to or clean up a directory other agents share, such as a session scratchpad. + +When done, summarize any issues encounted, and suggest potential follow-up. +If you're happy with the outcome, switch the draft PR to ready for review. +If you want another set of eyes on it, keep it a draft. diff --git a/.github/scripts/package-binary.test.sh b/.github/scripts/package-binary.test.sh index 2b2554b6c1..b1390552f1 100755 --- a/.github/scripts/package-binary.test.sh +++ b/.github/scripts/package-binary.test.sh @@ -42,3 +42,31 @@ cmp "$binary" "$bare" cmp "$binary" "$tmp/extracted/$name/bin/moq-relay" echo "release assets package together without path collisions" + +# Without --binary the script builds the flake package named after the binary: +# `.#moq` for the moq-cli crate, since `.#moq-cli` is a stub refusing the old name. +cat >"$tmp/bin/nix" <<'NIX' +#!/usr/bin/env bash +set -euo pipefail +[[ "$1 $3" == "build --out-link" && "$2" == *"#moq" ]] || { + echo "unexpected: nix $*" >&2 + exit 1 +} +mkdir -p "$4/bin" +printf '#!/usr/bin/env sh\necho moq\n' >"$4/bin/moq" +chmod 0755 "$4/bin/moq" +NIX +chmod 0755 "$tmp/bin/nix" + +PATH="$tmp/bin:$PATH" "$WORKSPACE_DIR/rs/scripts/package-binary.sh" \ + --crate moq-cli \ + --bin moq \ + --version 0.12.2 \ + --target "$target" \ + --output "$tmp/dist" + +name="moq-cli-v0.12.2-$target" +tar -xzf "$tmp/dist/$name.tar.gz" -C "$tmp/extracted" +[[ "$("$tmp/extracted/$name/bin/moq")" == moq ]] + +echo "a nix build packages the flake output named after the binary" diff --git a/.github/workflows/cache.yml b/.github/workflows/cache.yml index 639de6c3f0..f6a5e4d433 100644 --- a/.github/workflows/cache.yml +++ b/.github/workflows/cache.yml @@ -104,13 +104,13 @@ jobs: # and the test artifacts (codegen + linked test binaries). They share # little, and a PR usually needs both. - name: Check - run: nix develop --command just check --all + run: nix develop --command just ci check --all env: MOQ_STRICT: 1 - name: Test if: ${{ !cancelled() }} - run: nix develop --command just test all + run: nix develop --command just ci test --all env: MOQ_STRICT: 1 NEXTEST_PROFILE: ci diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a1b03fd00c..4b26c72456 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -41,7 +41,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Full history so `just check` can diff against origin/$GITHUB_BASE_REF. + # Full history so `just ci check` can diff against origin/$GITHUB_BASE_REF. fetch-depth: 0 - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main @@ -77,7 +77,7 @@ jobs: run: bash .claude/hooks/direnv.test.sh - name: Check - run: nix develop --command just check + run: nix develop --command just ci check env: MOQ_STRICT: 1 @@ -97,7 +97,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Full history so `just test` can diff against origin/$GITHUB_BASE_REF. + # Full history so `just ci test` can diff against origin/$GITHUB_BASE_REF. fetch-depth: 0 - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main @@ -120,7 +120,7 @@ jobs: # NEXTEST_PROFILE picks up the longer hang timeout in .config/nextest.toml; # without it a runner under load could trip the local one. - name: Test - run: nix develop --command just test + run: nix develop --command just ci test env: MOQ_STRICT: 1 NEXTEST_PROFILE: ci diff --git a/.gitignore b/.gitignore index 414686073c..ec8f692718 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ /.claude/tmp/ /.claude/worktrees/ +# Per-worktree agent scratch (PR bodies, logs, notes); see the start-quest skill. +/.scratch/ + # IDE .idea diff --git a/CLAUDE.md b/CLAUDE.md index a94be3ee39..ba39b50d64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ This file is split into nested `CLAUDE.md` files based on the language/situation - Try to do stuff asynchronously. ex. ask about follow-ups while tests run. - Try to recognize when you're stuck, or making minimal progress, and stop early. - If the core problem is addressed, ship it instead of spinning your wheels on meaningless revisions. -- Benchmark any performance optimizations instead of relying on intuition. +- Add or extend a benchmark for any performance-sensitive change so later regressions show up, and measure optimizations instead of relying on intuition. # Public API @@ -83,8 +83,7 @@ Use the Nix dev shell so tooling matches CI. direnv loads it automatically, but if not: `nix develop --command ...`. ```bash -just check # Lint and compile what the branch changed -just test # Test what the branch changed, same scope +just check # Lint, compile, and test what the branch changed just fix # Auto-fix lint/formatting, same scope ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bf08999dd..10c8135fdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ When pushing additional commits to an existing PR, update the title and descript When taking over someone else's PR, push commits on top of theirs so they keep credit. Create a draft PR. -Switch it to "Ready for review" when you're finished and local `just check` and `just test` pass. +Switch it to "Ready for review" when you're finished and local `just check` passes. Fix any merge conflicts and failing CI checks. # AI diff --git a/Cargo.lock b/Cargo.lock index c648a8d0a6..08d5838edf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.2" +version = "0.21.4" dependencies = [ "anyhow", "bytes", @@ -3454,9 +3454,9 @@ dependencies = [ [[package]] name = "iroh-metrics" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +checksum = "8ede55536349842337f7cdc63012ec33dc637945f3259c98a0763aac364cdf09" dependencies = [ "iroh-metrics-derive", "itoa", @@ -3884,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.2" +version = "0.6.4" dependencies = [ "anyhow", "bytes", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.2" +version = "0.0.4" dependencies = [ "async-trait", "bytes", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.1" +version = "0.1.3" dependencies = [ "block2 0.6.2", "bytes", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "moq-auth" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "aws-lc-rs", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.1" +version = "0.1.3" dependencies = [ "bytes", "kio 0.6.0", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.2" +version = "0.5.4" dependencies = [ "anyhow", "boytacean", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.2" +version = "0.12.4" dependencies = [ "anyhow", "axum", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.2" +version = "0.0.4" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.2" +version = "0.4.4" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4365,7 +4365,7 @@ dependencies = [ [[package]] name = "moq-flate" -version = "0.1.3" +version = "0.2.0" dependencies = [ "bytes", "flate2", @@ -4374,10 +4374,11 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.2" +version = "0.4.4" dependencies = [ "anyhow", "bytes", + "futures", "gst-plugin-version-helper", "gstreamer", "hang", @@ -4392,7 +4393,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.2" +version = "0.5.4" dependencies = [ "axum", "bytes", @@ -4416,7 +4417,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.4.2" +version = "0.5.1" dependencies = [ "bytes", "criterion", @@ -4433,7 +4434,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.10" +version = "0.2.12" dependencies = [ "bytes", "moq-net", @@ -4451,7 +4452,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.2" +version = "0.10.4" dependencies = [ "anyhow", "base64 0.23.1", @@ -4488,7 +4489,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.1" +version = "0.3.3" dependencies = [ "arrayvec", "bytes", @@ -4605,7 +4606,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.2" +version = "0.15.4" dependencies = [ "anyhow", "axum", @@ -4648,7 +4649,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.2" +version = "0.2.4" dependencies = [ "kio 0.6.0", "moq-auth", @@ -4662,7 +4663,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.2" +version = "0.3.4" dependencies = [ "aws-lc-rs", "axum", @@ -4682,7 +4683,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.2" +version = "0.3.4" dependencies = [ "anyhow", "byteorder", @@ -4730,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.2" +version = "0.3.4" dependencies = [ "bytes", "futures", @@ -4746,7 +4747,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.2" +version = "0.2.4" dependencies = [ "futures", "moq-json", @@ -4761,7 +4762,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.13" +version = "0.19.15" dependencies = [ "anyhow", "bytes", @@ -4814,7 +4815,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.1" +version = "0.1.3" dependencies = [ "anyhow", "bytes", @@ -4833,7 +4834,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.3" +version = "0.0.5" dependencies = [ "anyhow", "bytes", @@ -4890,7 +4891,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.1" +version = "0.1.3" dependencies = [ "anyhow", "ash", @@ -7429,9 +7430,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -7450,9 +7451,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -8040,9 +8041,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smawk" diff --git a/Cargo.toml b/Cargo.toml index 6751e2b493..e523d0943d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,10 +118,10 @@ criterion = "0.8" # (`fallback-dynamic-loading`), so nothing links against libcuda at build time. cudarc = { version = "0.19", default-features = false, features = ["driver", "fallback-dynamic-loading", "cuda-12020"] } dispatch2 = "0.3.1" -flate2 = "1.1" +flate2 = { version = "1.1", default-features = false } futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.2", path = "rs/hang" } +hang = { version = "0.21.4", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -139,16 +139,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.1.1", path = "rs/moq-audio", default-features = false } -moq-auth = { version = "0.1.0", path = "rs/moq-auth" } -moq-binary = { version = "0.1.1", path = "rs/moq-binary" } -moq-flate = { version = "0.1.3", path = "rs/moq-flate" } -moq-hls = { version = "0.5.2", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.4.2", path = "rs/moq-json" } -moq-loc = { version = "0.2.10", path = "rs/moq-loc" } +moq-audio = { version = "0.1.3", path = "rs/moq-audio", default-features = false } +moq-auth = { version = "0.1.1", path = "rs/moq-auth" } +moq-binary = { version = "0.1.3", path = "rs/moq-binary" } +moq-flate = { version = "0.2.0", path = "rs/moq-flate" } +moq-hls = { version = "0.5.4", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.5.1", path = "rs/moq-json" } +moq-loc = { version = "0.2.12", path = "rs/moq-loc" } moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.2", path = "rs/moq-mux" } -moq-net = { version = "0.3.1", path = "rs/moq-net" } +moq-mux = { version = "0.10.4", path = "rs/moq-mux" } +moq-net = { version = "0.3.3", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. moq-noq-proto = { version = "1.3", default-features = false } @@ -158,23 +158,23 @@ moq-noq-udp = "1.3" # used by moq-video on Linux. moq-nvenc = { version = "0.1.0", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.2", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.2", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.2", path = "rs/moq-rtmp" } +moq-relay = { version = "0.15.4", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.4", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.4", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.2", path = "rs/moq-srt" } -moq-stats = { version = "0.2.2", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.13", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.4", path = "rs/moq-srt" } +moq-stats = { version = "0.2.4", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.15", path = "rs/moq-tokio", default-features = false } # Default features off on moq-transcode and moq-video so each workspace consumer # chooses native codecs, OpenH264, and rendering explicitly. Both crates still # provide working native plus software defaults when depended on directly. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.1.1", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.3", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. -moq-uring = { version = "0.0.3", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.5", path = "rs/moq-uring", default-features = false } # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. @@ -187,7 +187,7 @@ moq-vaapi = "0.1.0" # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and libmoq opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.1.1", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.3", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } diff --git a/README.md b/README.md index af82693845..1da6f96877 100644 --- a/README.md +++ b/README.md @@ -122,22 +122,18 @@ just # Build everything just build -# Lint and compile what your branch changed +# Lint, compile, and test what your branch changed just check -# Test what your branch changed, same scope -just test - # Automatically fix some linting errors, same scope just fix # Same as the above, over every package just check --all -just test all just fix --all ``` -CI runs these same two recipes, so they cover the same ground locally. It sets two things you don't: `MOQ_STRICT=1`, which turns a missing tool into an error instead of a skipped check, and `NEXTEST_PROFILE=ci`, which allows a longer hang timeout. +CI runs `check` as two parallel jobs, `just ci check` and `just ci test`, so it covers the same ground as your local run. It sets two things you don't: `MOQ_STRICT=1`, which turns a missing tool into an error instead of a skipped check, and `NEXTEST_PROFILE=ci`, which allows a longer hang timeout. See the [development guide](https://doc.moq.dev/setup/dev) and the [justfile](justfile) for more. diff --git a/bun.lock b/bun.lock index b53786bfe9..49a1ec1627 100644 --- a/bun.lock +++ b/bun.lock @@ -99,6 +99,7 @@ "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", + "@moq/signals": "workspace:^", }, "devDependencies": { "@types/bun": "^1.4.2", @@ -655,6 +656,10 @@ "@moq/hang": ["@moq/hang@workspace:js/hang"], + "@moq/interop-browser": ["@moq/interop-browser@workspace:test/interop/clients/js"], + + "@moq/interop-native": ["@moq/interop-native@workspace:test/interop/clients/js-native"], + "@moq/json": ["@moq/json@workspace:js/json"], "@moq/loc": ["@moq/loc@workspace:js/loc"], @@ -673,10 +678,6 @@ "@moq/signals": ["@moq/signals@workspace:js/signals"], - "@moq/interop-browser": ["@moq/interop-browser@workspace:test/interop/clients/js"], - - "@moq/interop-native": ["@moq/interop-native@workspace:test/interop/clients/js-native"], - "@moq/wasm": ["@moq/wasm@workspace:js/wasm"], "@moq/wasm-test": ["@moq/wasm-test@workspace:test/wasm"], diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 97ee873f98..bbe39e6879 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -538,12 +538,19 @@ void MoQOutput::AudioData(struct encoder_packet *packet) // Audio has no keyframes, so it has no group boundary of its own: without this the whole // stream is one group. Cut per frame, which is one QUIC stream per packet forwarded without // waiting for the next, the right trade for live. Video groups at its own keyframes. + // Cut before observing the flush so a failed observation never leaves the group open. result = moq_publish_media_cut(handle); if (result < 0) { LOG_ERROR("Failed to cut audio group: %d", result); return; } + result = moq_publish_media_flush(handle, pts_us); + if (result < 0) { + LOG_ERROR("Failed to observe audio encoder flush: %d", result); + return; + } + total_bytes_sent += packet->size; } @@ -576,6 +583,12 @@ void MoQOutput::VideoData(struct encoder_packet *packet) return; } + result = moq_publish_media_flush(handle, pts_us); + if (result < 0) { + LOG_ERROR("Failed to observe video encoder flush: %d", result); + return; + } + total_bytes_sent += packet->size; } diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index 8aec31a46a..e28039ea60 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -49,6 +49,8 @@ std::atomic g_begin_capture{0}; std::atomic g_settings_ok{true}; std::string g_rate_control = "CBR"; moq_video_hint g_video_hint{}; +std::atomic g_video_flushes{0}; +std::atomic g_audio_flushes{0}; // Lets a test run something inside obs_output_signal_stop, standing in for a // frontend that stops the output straight from the signal handler. std::function g_on_signal; @@ -139,9 +141,9 @@ bool obs_encoder_get_extra_data(const obs_encoder_t *, uint8_t **, size_t *) return false; } -const char *obs_encoder_get_codec(const obs_encoder_t *) +const char *obs_encoder_get_codec(const obs_encoder_t *encoder) { - return "h264"; + return encoder == reinterpret_cast(0x3) ? "opus" : "h264"; } // VideoInit reads coded size and CBR from the encoder. Any new libobs call in @@ -244,7 +246,7 @@ int32_t moq_publish_video(uint32_t, const moq_video_init *config) int32_t moq_publish_audio(uint32_t, const moq_audio_init *) { - return 7; + return 8; } int32_t moq_publish_media_finish(uint32_t) @@ -257,6 +259,15 @@ int32_t moq_publish_media_frame(uint32_t, const uint8_t *, uintptr_t, uint64_t) return 0; } +int32_t moq_publish_media_flush(uint32_t handle, uint64_t) +{ + if (handle == 7) + g_video_flushes++; + else if (handle == 8) + g_audio_flushes++; + return 0; +} + int32_t moq_publish_media_cut(uint32_t) { return 0; @@ -403,6 +414,8 @@ void reset() g_settings_ok = true; g_rate_control = "CBR"; g_video_hint = {}; + g_video_flushes = 0; + g_audio_flushes = 0; g_start_gate = nullptr; g_connect_fires_terminal = false; g_connect_fires_terminal_threaded = false; @@ -438,6 +451,14 @@ int main() packet.timebase_num = 1; packet.timebase_den = 30; o.Data(&packet); + CHECK(g_video_flushes == 1); + encoder_packet audio{}; + audio.type = OBS_ENCODER_AUDIO; + audio.encoder = reinterpret_cast(0x3); + audio.timebase_num = 1; + audio.timebase_den = 48000; + o.Data(&audio); + CHECK(g_audio_flushes == 1); CHECK(g_video_hint.has_coded); CHECK(g_video_hint.has_bitrate == (g_rate_control == "CBR")); if (g_video_hint.has_bitrate) diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 1f43856f01..8727301bc0 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -43,7 +43,7 @@ typedef BroadcastRequest = MoqBroadcastRequest; /// A stream of route announcements and retractions under a prefix. typedef AnnounceConsumer = MoqAnnounceConsumer; -/// A literal prefix plus an optional relative pattern for announcement discovery. +/// A literal prefix, an optional relative pattern, and the hidden-path opt-in for announcement discovery. typedef AnnounceConfig = MoqAnnounceConfig; /// A pending wait for a route to cover a specific path. diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 3a821799d8..714fda2829 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -8,9 +8,13 @@ final class AnnounceOptions { /// Pattern relative to [prefix], or null for every path beneath it. final String? filter; - const AnnounceOptions({this.prefix = '', this.filter}); + /// Also list paths with a segment starting with `.` below [prefix]. + final bool hidden; - AnnounceConfig get _ffi => AnnounceConfig(prefix: prefix, filter: filter); + const AnnounceOptions({this.prefix = '', this.filter, this.hidden = false}); + + AnnounceConfig get _ffi => + AnnounceConfig(prefix: prefix, filter: filter, hidden: hidden); } /// Everything [Moq.connect] can be told beyond the URL. diff --git a/dart/moq_ffi/dart_test.yaml b/dart/moq_ffi/dart_test.yaml new file mode 100644 index 0000000000..d11c7b216b --- /dev/null +++ b/dart/moq_ffi/dart_test.yaml @@ -0,0 +1,3 @@ +# Suites share one process, so a concurrent suite would add its allocations to +# the resident memory that leak_test.dart measures. +concurrency: 1 diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 92e68c91cb..4ada2c2a1c 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -14,6 +14,65 @@ import "package:ffi/ffi.dart"; import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; +class MoqBinaryConfig { + final bool compression; + final String? mime; + MoqBinaryConfig({this.compression = false, this.mime = null}); +} + +class FfiConverterMoqBinaryConfig { + static MoqBinaryConfig lift(RustBuffer buf) { + return FfiConverterMoqBinaryConfig.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final compression_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final compression = compression_lifted.value; + new_offset += compression_lifted.bytesRead; + final mime_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final mime = mime_lifted.value; + new_offset += mime_lifted.bytesRead; + return LiftRetVal( + MoqBinaryConfig(compression: compression, mime: mime), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqBinaryConfig value) { + final total_length = + FfiConverterBool.allocationSize(value.compression) + + FfiConverterOptionalString.allocationSize(value.mime) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqBinaryConfig value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterBool.write( + value.compression, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalString.write( + value.mime, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqBinaryConfig value) { + return FfiConverterBool.allocationSize(value.compression) + + FfiConverterOptionalString.allocationSize(value.mime) + + 0; + } +} + class MoqFetchGroupOptions { final int priority; MoqFetchGroupOptions({this.priority = 0}); @@ -500,7 +559,13 @@ class MoqAudioInit { final MoqAudioFormat format; final Uint8List data; final String? label; - MoqAudioInit({required this.format, required this.data, this.label = null}); + final String? track; + MoqAudioInit({ + required this.format, + required this.data, + this.label = null, + this.track = null, + }); } class FfiConverterMoqAudioInit { @@ -525,8 +590,13 @@ class FfiConverterMoqAudioInit { ); final label = label_lifted.value; new_offset += label_lifted.bytesRead; + final track_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final track = track_lifted.value; + new_offset += track_lifted.bytesRead; return LiftRetVal( - MoqAudioInit(format: format, data: data, label: label), + MoqAudioInit(format: format, data: data, label: label, track: track), new_offset - buf.offsetInBytes, ); } @@ -536,6 +606,7 @@ class FfiConverterMoqAudioInit { FfiConverterMoqAudioFormat.allocationSize(value.format) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + + FfiConverterOptionalString.allocationSize(value.track) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -556,6 +627,10 @@ class FfiConverterMoqAudioInit { value.label, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.track, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } @@ -563,6 +638,7 @@ class FfiConverterMoqAudioInit { return FfiConverterMoqAudioFormat.allocationSize(value.format) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + + FfiConverterOptionalString.allocationSize(value.track) + 0; } } @@ -1308,11 +1384,13 @@ class MoqVideoInit { final Uint8List data; final String? label; final MoqVideoHint? hint; + final String? track; MoqVideoInit({ required this.format, required this.data, this.label = null, this.hint = null, + this.track = null, }); } @@ -1343,8 +1421,19 @@ class FfiConverterMoqVideoInit { ); final hint = hint_lifted.value; new_offset += hint_lifted.bytesRead; + final track_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final track = track_lifted.value; + new_offset += track_lifted.bytesRead; return LiftRetVal( - MoqVideoInit(format: format, data: data, label: label, hint: hint), + MoqVideoInit( + format: format, + data: data, + label: label, + hint: hint, + track: track, + ), new_offset - buf.offsetInBytes, ); } @@ -1355,6 +1444,7 @@ class FfiConverterMoqVideoInit { FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalMoqVideoHint.allocationSize(value.hint) + + FfiConverterOptionalString.allocationSize(value.track) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1379,6 +1469,10 @@ class FfiConverterMoqVideoInit { value.hint, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.track, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } @@ -1387,6 +1481,7 @@ class FfiConverterMoqVideoInit { FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalMoqVideoHint.allocationSize(value.hint) + + FfiConverterOptionalString.allocationSize(value.track) + 0; } } @@ -1469,7 +1564,12 @@ class FfiConverterMoqVideoProperties { class MoqAnnounceConfig { final String prefix; final String? filter; - MoqAnnounceConfig({this.prefix = '', this.filter = null}); + final bool hidden; + MoqAnnounceConfig({ + this.prefix = '', + this.filter = null, + this.hidden = false, + }); } class FfiConverterMoqAnnounceConfig { @@ -1489,8 +1589,13 @@ class FfiConverterMoqAnnounceConfig { ); final filter = filter_lifted.value; new_offset += filter_lifted.bytesRead; + final hidden_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final hidden = hidden_lifted.value; + new_offset += hidden_lifted.bytesRead; return LiftRetVal( - MoqAnnounceConfig(prefix: prefix, filter: filter), + MoqAnnounceConfig(prefix: prefix, filter: filter, hidden: hidden), new_offset - buf.offsetInBytes, ); } @@ -1499,6 +1604,7 @@ class FfiConverterMoqAnnounceConfig { final total_length = FfiConverterString.allocationSize(value.prefix) + FfiConverterOptionalString.allocationSize(value.filter) + + FfiConverterBool.allocationSize(value.hidden) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1515,12 +1621,17 @@ class FfiConverterMoqAnnounceConfig { value.filter, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterBool.write( + value.hidden, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } static int allocationSize(MoqAnnounceConfig value) { return FfiConverterString.allocationSize(value.prefix) + FfiConverterOptionalString.allocationSize(value.filter) + + FfiConverterBool.allocationSize(value.hidden) + 0; } } @@ -3950,6 +4061,164 @@ class FfiConverterMoqReservation { } } +abstract class MoqBinarySnapshotProducerInterface { + void finish(); + void update({required Uint8List payload}); +} + +final _MoqBinarySnapshotProducerFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer(ptr, status), + ); +}); + +class MoqBinarySnapshotProducer implements MoqBinarySnapshotProducerInterface { + late final Pointer _ptr; + MoqBinarySnapshotProducer._(this._ptr) { + _MoqBinarySnapshotProducerFinalizer.attach(this, _ptr, detach: this); + } + factory MoqBinarySnapshotProducer.lift(Pointer ptr) { + return MoqBinarySnapshotProducer._(ptr); + } + Pointer uniffiClonePointer() { + return rustCall( + (status) => + uniffi_moq_ffi_fn_clone_moqbinarysnapshotproducer(_ptr, status), + ); + } + + void dispose() { + _MoqBinarySnapshotProducerFinalizer.detach(this); + rustCall( + (status) => + uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer(_ptr, status), + ); + } + + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_finish( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + + void update({required Uint8List payload}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_update( + uniffiClonePointer(), + FfiConverterUint8List.lower(payload), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqBinarySnapshotProducer { + static MoqBinarySnapshotProducer lift(Pointer ptr) { + return MoqBinarySnapshotProducer.lift(ptr); + } + + static Pointer lower(MoqBinarySnapshotProducer value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqBinarySnapshotProducer value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqBinarySnapshotProducer.lift(pointer), 8); + } + + static int write(MoqBinarySnapshotProducer value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqBinaryStreamProducerInterface { + void append({required Uint8List payload}); + void finish(); +} + +final _MoqBinaryStreamProducerFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarystreamproducer(ptr, status), + ); +}); + +class MoqBinaryStreamProducer implements MoqBinaryStreamProducerInterface { + late final Pointer _ptr; + MoqBinaryStreamProducer._(this._ptr) { + _MoqBinaryStreamProducerFinalizer.attach(this, _ptr, detach: this); + } + factory MoqBinaryStreamProducer.lift(Pointer ptr) { + return MoqBinaryStreamProducer._(ptr); + } + Pointer uniffiClonePointer() { + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqbinarystreamproducer(_ptr, status), + ); + } + + void dispose() { + _MoqBinaryStreamProducerFinalizer.detach(this); + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarystreamproducer(_ptr, status), + ); + } + + void append({required Uint8List payload}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarystreamproducer_append( + uniffiClonePointer(), + FfiConverterUint8List.lower(payload), + status, + ); + }, moqExceptionErrorHandler); + } + + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarystreamproducer_finish( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqBinaryStreamProducer { + static MoqBinaryStreamProducer lift(Pointer ptr) { + return MoqBinaryStreamProducer.lift(ptr); + } + + static Pointer lower(MoqBinaryStreamProducer value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqBinaryStreamProducer value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqBinaryStreamProducer.lift(pointer), 8); + } + + static int write(MoqBinaryStreamProducer value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqBroadcastConsumerInterface { Future fetchGroup({ required String name, @@ -5839,6 +6108,14 @@ class FfiConverterMoqBroadcastDynamic { } abstract class MoqBroadcastProducerInterface { + MoqBinarySnapshotProducer publishBinarySnapshot({ + required String name, + required MoqBinaryConfig config, + }); + MoqBinaryStreamProducer publishBinaryStream({ + required String name, + required MoqBinaryConfig config, + }); MoqJsonSnapshotProducer publishJsonSnapshot({ required String name, required MoqJsonSnapshotConfig config, @@ -5911,6 +6188,40 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { ); } + MoqBinarySnapshotProducer publishBinarySnapshot({ + required String name, + required MoqBinaryConfig config, + }) { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_snapshot( + uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterMoqBinaryConfig.lower(config), + status, + ), + FfiConverterMoqBinarySnapshotProducer.lift, + moqExceptionErrorHandler, + ); + } + + MoqBinaryStreamProducer publishBinaryStream({ + required String name, + required MoqBinaryConfig config, + }) { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_stream( + uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterMoqBinaryConfig.lower(config), + status, + ), + FfiConverterMoqBinaryStreamProducer.lift, + moqExceptionErrorHandler, + ); + } + MoqJsonSnapshotProducer publishJsonSnapshot({ required String name, required MoqJsonSnapshotConfig config, @@ -6561,6 +6872,7 @@ abstract class MoqMediaProducerInterface { void cut(); MoqTrackDemand demand(); void finish(); + void flush({required int timestampUs}); String name(); void seek({required int sequence}); Future unused(); @@ -6620,6 +6932,16 @@ class MoqMediaProducer implements MoqMediaProducerInterface { }, moqExceptionErrorHandler); } + void flush({required int timestampUs}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqmediaproducer_flush( + uniffiClonePointer(), + FfiConverterUInt64.lower(timestampUs), + status, + ); + }, moqExceptionErrorHandler); + } + String name() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqmediaproducer_name( @@ -8116,14 +8438,9 @@ class FfiConverterOptionalBool { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalBool.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalBool.allocationSize(value)); FfiConverterOptionalBool.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(bool? value, Uint8List buf) { @@ -8166,14 +8483,9 @@ class FfiConverterOptionalDouble64 { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalDouble64.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalDouble64.allocationSize(value)); FfiConverterOptionalDouble64.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(double? value, Uint8List buf) { @@ -8216,14 +8528,11 @@ class FfiConverterOptionalMoqAnnounceUpdate { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqAnnounceUpdate.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqAnnounceUpdate.allocationSize(value), + ); FfiConverterOptionalMoqAnnounceUpdate.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqAnnounceUpdate? value, Uint8List buf) { @@ -8266,14 +8575,9 @@ class FfiConverterOptionalMoqCatalog { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqCatalog.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalMoqCatalog.allocationSize(value)); FfiConverterOptionalMoqCatalog.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqCatalog? value, Uint8List buf) { @@ -8316,14 +8620,11 @@ class FfiConverterOptionalMoqDatagram { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqDatagram.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqDatagram.allocationSize(value), + ); FfiConverterOptionalMoqDatagram.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqDatagram? value, Uint8List buf) { @@ -8366,14 +8667,11 @@ class FfiConverterOptionalMoqDimensions { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqDimensions.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqDimensions.allocationSize(value), + ); FfiConverterOptionalMoqDimensions.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqDimensions? value, Uint8List buf) { @@ -8421,16 +8719,11 @@ class FfiConverterOptionalMoqFetchGroupOptions { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqFetchGroupOptions.allocationSize( - value, + final buf = Uint8List( + FfiConverterOptionalMoqFetchGroupOptions.allocationSize(value), ); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); FfiConverterOptionalMoqFetchGroupOptions.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqFetchGroupOptions? value, Uint8List buf) { @@ -8473,14 +8766,9 @@ class FfiConverterOptionalMoqFrame { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqFrame.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalMoqFrame.allocationSize(value)); FfiConverterOptionalMoqFrame.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqFrame? value, Uint8List buf) { @@ -8523,14 +8811,11 @@ class FfiConverterOptionalMoqGroupConsumer { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqGroupConsumer.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqGroupConsumer.allocationSize(value), + ); FfiConverterOptionalMoqGroupConsumer.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqGroupConsumer? value, Uint8List buf) { @@ -8573,14 +8858,11 @@ class FfiConverterOptionalMoqMediaFrame { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqMediaFrame.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqMediaFrame.allocationSize(value), + ); FfiConverterOptionalMoqMediaFrame.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqMediaFrame? value, Uint8List buf) { @@ -8623,14 +8905,11 @@ class FfiConverterOptionalMoqOriginProducer { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqOriginProducer.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqOriginProducer.allocationSize(value), + ); FfiConverterOptionalMoqOriginProducer.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqOriginProducer? value, Uint8List buf) { @@ -8673,14 +8952,9 @@ class FfiConverterOptionalMoqRequest { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqRequest.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalMoqRequest.allocationSize(value)); FfiConverterOptionalMoqRequest.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqRequest? value, Uint8List buf) { @@ -8723,14 +8997,11 @@ class FfiConverterOptionalMoqSubscription { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqSubscription.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqSubscription.allocationSize(value), + ); FfiConverterOptionalMoqSubscription.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqSubscription? value, Uint8List buf) { @@ -8773,14 +9044,11 @@ class FfiConverterOptionalMoqTrackInfo { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqTrackInfo.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqTrackInfo.allocationSize(value), + ); FfiConverterOptionalMoqTrackInfo.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqTrackInfo? value, Uint8List buf) { @@ -8823,14 +9091,11 @@ class FfiConverterOptionalMoqVideoHint { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqVideoHint.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqVideoHint.allocationSize(value), + ); FfiConverterOptionalMoqVideoHint.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqVideoHint? value, Uint8List buf) { @@ -8873,14 +9138,11 @@ class FfiConverterOptionalSequenceString { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalSequenceString.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalSequenceString.allocationSize(value), + ); FfiConverterOptionalSequenceString.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(List? value, Uint8List buf) { @@ -8923,14 +9185,9 @@ class FfiConverterOptionalString { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalString.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalString.allocationSize(value)); FfiConverterOptionalString.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(String? value, Uint8List buf) { @@ -8973,14 +9230,9 @@ class FfiConverterOptionalUInt64 { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalUInt64.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalUInt64.allocationSize(value)); FfiConverterOptionalUInt64.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(int? value, Uint8List buf) { @@ -9023,14 +9275,9 @@ class FfiConverterOptionalUint8List { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalUint8List.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalUint8List.allocationSize(value)); FfiConverterOptionalUint8List.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(Uint8List? value, Uint8List buf) { @@ -9232,7 +9479,7 @@ class FfiConverterUint8List { static LiftRetVal read(Uint8List buf) { final length = buf.buffer.asByteData(buf.offsetInBytes).getInt32(0); - final bytes = Uint8List.view(buf.buffer, buf.offsetInBytes + 4, length); + final bytes = buf.sublist(4, 4 + length); return LiftRetVal(bytes, length + 4); } @@ -9330,6 +9577,72 @@ external void uniffi_moq_ffi_fn_method_moqreservation_update( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqbinarysnapshotproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_finish( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_update( + Pointer ptr, + RustBuffer payload, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqbinarystreamproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqbinarystreamproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarystreamproducer_append( + Pointer ptr, + RustBuffer payload, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarystreamproducer_finish( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10144,6 +10457,38 @@ external Pointer uniffi_moq_ffi_fn_constructor_moqbroadcastproducer_new( Pointer uniffiStatus, ); +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_snapshot( + Pointer ptr, + RustBuffer name, + RustBuffer config, + Pointer uniffiStatus, +); + +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_stream( + Pointer ptr, + RustBuffer name, + RustBuffer config, + Pointer uniffiStatus, +); + @Native< Pointer Function( Pointer, @@ -10575,6 +10920,15 @@ external void uniffi_moq_ffi_fn_method_moqmediaproducer_finish( Pointer uniffiStatus, ); +@Native, Uint64, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqmediaproducer_flush( + Pointer ptr, + int timestamp_us, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -11623,6 +11977,18 @@ external int uniffi_moq_ffi_checksum_method_moqreservation_grant(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqreservation_update(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_finish(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_update(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_append(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_finish(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group(); @@ -11817,6 +12183,14 @@ external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel(); external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track(); +@Native(assetId: _uniffiAssetId) +external int +uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_snapshot(); + +@Native(assetId: _uniffiAssetId) +external int +uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_stream(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot(); @@ -11938,6 +12312,9 @@ external int uniffi_moq_ffi_checksum_method_moqmediaproducer_demand(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqmediaproducer_finish(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqmediaproducer_flush(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqmediaproducer_name(); @@ -12186,6 +12563,21 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqreservation_update() != 9626) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_finish() != + 10338) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_update() != + 56077) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_append() != 1645) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_finish() != + 60630) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group() != 18633) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); @@ -12388,12 +12780,20 @@ void _checkApiChecksums() { 24118) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_snapshot() != + 6748) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_stream() != + 58418) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot() != - 51036) { + 64276) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream() != - 47317) { + 54975) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { @@ -12409,7 +12809,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio() != - 47444) { + 31691) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio_on_track() != @@ -12512,6 +12912,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqmediaproducer_finish() != 38480) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqmediaproducer_flush() != 10235) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqmediaproducer_name() != 7199) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/dart/moq_ffi/lib/src/uniffi_runtime.dart b/dart/moq_ffi/lib/src/uniffi_runtime.dart index ef3dc04b16..ab87a64ed3 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -71,11 +71,11 @@ void checkCallStatus( if (status.ref.code == CALL_SUCCESS) { return; } else if (status.ref.code == CALL_ERROR) { - throw errorHandler.lift(status.ref.errorBuf); + throw liftAndFree(status.ref.errorBuf, errorHandler.lift); } else if (status.ref.code == CALL_UNEXPECTED_ERROR) { if (status.ref.errorBuf.len > 0) { throw UniffiInternalError.panicked( - FfiConverterString.lift(status.ref.errorBuf), + liftAndFree(status.ref.errorBuf, FfiConverterString.lift), ); } else { throw UniffiInternalError.panicked("Rust panic"); @@ -101,6 +101,16 @@ T rustCall( } } +T liftAndFree(F raw, T Function(F) lifter) { + try { + return lifter(raw); + } finally { + if (raw is RustBuffer) { + raw.free(); + } + } +} + T rustCallWithLifter( F Function(Pointer) ffiCall, T Function(F) lifter, [ @@ -110,7 +120,7 @@ T rustCallWithLifter( try { final rawResult = ffiCall(status); checkCallStatus(errorHandler ?? NullRustCallStatusErrorHandler(), status); - return lifter(rawResult); + return liftAndFree(rawResult, lifter); } finally { calloc.free(status); } @@ -119,7 +129,6 @@ T rustCallWithLifter( class NullRustCallStatusErrorHandler extends UniffiRustCallStatusErrorHandler { @override Exception lift(RustBuffer errorBuf) { - errorBuf.free(); return UniffiInternalError.panicked("Unexpected CALL_ERROR"); } } @@ -175,7 +184,12 @@ RustBuffer toRustBuffer(Uint8List data) { final bytes = calloc(); bytes.ref.len = length; bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + try { + return RustBuffer.fromBytes(bytes.ref); + } finally { + calloc.free(frameData); + calloc.free(bytes); + } } ForeignBytes lowerForeignBytes(Uint8List data) { @@ -306,7 +320,7 @@ Future uniffiRustCallAsync( try { final result = completeFunc(rustFuture, status); checkCallStatus(errorHandler ?? NullRustCallStatusErrorHandler(), status); - return liftFunc(result); + return liftAndFree(result, liftFunc); } finally { calloc.free(status); } diff --git a/dart/moq_ffi/test/leak_test.dart b/dart/moq_ffi/test/leak_test.dart new file mode 100644 index 0000000000..fe8ce19431 --- /dev/null +++ b/dart/moq_ffi/test/leak_test.dart @@ -0,0 +1,91 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:moq_ffi/moq_ffi.dart'; +import 'package:test/test.dart'; + +// Each call moves `size` bytes across the FFI boundary, so a leak of that +// buffer grows resident memory by `iterations * size`. Growth under a quarter of +// that leaves room for allocator and Dart heap noise without hiding a leak. +const size = 64 * 1024; +const iterations = 2000; +const leaked = size * iterations; + +void main() { + test('a returned String is released', () { + final track = MoqBroadcastProducer().publishTrack( + name: 'x' * size, + info: null, + ); + // Warm up so one-time allocations do not count as growth. + for (var i = 0; i < 100; i++) { + track.name(); + } + + final before = ProcessInfo.currentRss; + for (var i = 0; i < iterations; i++) { + track.name(); + } + final growth = ProcessInfo.currentRss - before; + + expect(growth, lessThan(leaked ~/ 4)); + }); + + test('a non-null optional argument is released', () async { + // A local broadcast has no origin to resolve against, so each call lowers + // the optional String and then throws, also covering the error buffer. + final consumer = MoqBroadcastProducer().consume(); + final reference = 'x' * size; + Future call() => expectLater( + consumer.resolve(reference: reference), + throwsA(isA()), + ); + + for (var i = 0; i < 100; i++) { + await call(); + } + + final before = ProcessInfo.currentRss; + for (var i = 0; i < iterations; i++) { + await call(); + } + final growth = ProcessInfo.currentRss - before; + + expect(growth, lessThan(leaked ~/ 4)); + }); + + test('an async return is released', () async { + final payload = Uint8List(size); + + // Handles are released deterministically so the only growth left is what + // the bindings leak, not frames a live track still holds. + Future roundTrip() async { + final broadcast = MoqBroadcastProducer(); + final track = broadcast.publishTrack(name: 'frames', info: null); + final consumer = track.consume(subscription: null); + final producer = track.appendGroup(); + producer.writeFrame(frame: MoqFrame(payload: payload)); + producer.finish(); + final group = await consumer.nextGroup(); + final frame = await group!.readFrame(); + expect(frame!.payload.length, size); + group.dispose(); + producer.dispose(); + consumer.dispose(); + track.dispose(); + broadcast.dispose(); + } + + for (var i = 0; i < 100; i++) { + await roundTrip(); + } + + final before = ProcessInfo.currentRss; + for (var i = 0; i < iterations; i++) { + await roundTrip(); + } + final growth = ProcessInfo.currentRss - before; + + expect(growth, lessThan(leaked ~/ 4)); + }); +} diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 2174f1dcdc..5b6eb122e7 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -92,6 +92,7 @@ export default defineConfig({ { text: "moq-lite", link: "/concept/moq-lite" }, { text: "hang", link: "/concept/hang" }, { text: "Audio jitter", link: "/concept/audio-jitter" }, + { text: "Stats", link: "/concept/stats" }, { text: "Standards", link: "/concept/standard" }, { text: "Use cases", diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 01f38243ac..1376686f9d 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -270,6 +270,12 @@ See [Authentication](/bin/relay/auth). groups fetchable, which the [HLS gateway](/bin/hls) depends on. `export --max-age` (default 500 ms) is how long *this* consumer waits for a stalled group before skipping. Raising the first never delays playback. +For `export ts`, `--max-age` also bounds how long the muxer holds a leading +track for a lagging one. Frames go out in media-time order across all tracks, +not arrival order, so two exporters of one broadcast emit them in one order. A +track quiet for longer is muxed around until it catches up; a sparse track +(SCTE-35) costs that wait once per cue. `--max-age 0` keeps arrival order. + ## Debugging `RUST_LOG=debug` prints the negotiated version and every subscription. diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index e60965a359..99bc063da5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -21,7 +21,7 @@ nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -e \ # Publish a test pattern gst-launch-1.0 -e videotestsrc is-live=true ! x264enc tune=zerolatency ! h264parse \ ! video/x-h264,stream-format=byte-stream,alignment=au ! mux.sink_0 \ - moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang + moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang sink_0::encoder=true ``` Install via `apt install gstreamer1.0-moq` or `dnf install gstreamer1-moq` @@ -48,7 +48,8 @@ directly. A cue with no duration is dropped rather than left on screen. Each `sink_%u` request pad is one track. Pad properties: `track` names it (default: after the codec), `container=loc` publishes it as -[LOC](/concept/standard#loc) instead of the legacy hang container, and +[LOC](/concept/standard#loc) instead of the legacy hang container, +`encoder=true` marks it as fed by a local encoder, and `track-status`/`track-error` report its lifecycle. Element properties: `url`, `broadcast`, `tls-disable-verify`, `quic-idle-timeout`, `quic-keep-alive`, and read-only `status`, `connected`, `moq-version`, and @@ -73,6 +74,14 @@ while connected and 0 otherwise, reconnects are `started - 1` once `started` is at least 1, and a rate is the delta over any window you sample. Unlike `status`, a connection that drops before you poll still moves both counters. +Set `encoder=true` on audio and video pads a local encoder feeds +(`x264enc`, `opusenc`, ...). The pad then measures how late each frame reaches +the sink behind its running time and raises the catalog `jitter` by the spread, +so players buffer for an encoder that delivers irregularly. Leave it off, the +default, for file, demuxed, and network media: their arrival reflects the disk +or the network, not the original encoder, and a GStreamer segment cannot tell +the two apart. Text and opaque pads refuse it. + ## moqsrc Pads are named by kind and appear as the catalog announces renditions: diff --git a/doc/bin/obs.md b/doc/bin/obs.md index c8c3518b9a..e22801d357 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -33,6 +33,10 @@ OBS Studio install. **About** lists plugin and libmoq versions, documentation links, and available video encoders. +OBS reports each locally encoded packet's handoff to libmoq against the shared +broadcast media clock. Each track's catalog `jitter` is the largest measured +delay above that track's own recent minimum, rounded up to milliseconds. + ## Source quality and moq-transcode OBS publishes **one** hang mezzanine. It does not encode a viewer ladder inside diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 75d792c36f..6f8677ac36 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -201,19 +201,12 @@ node = "sjc/1" # Disambiguates relays sharing a cluster. depth = 1 # Also bucket by the first N path segments (per tenant). ``` -Each stats broadcast carries `publisher.json`, `subscriber.json`, and -`sessions.json` tracks (plus compressed `.z` twins) with cumulative counters -per broadcast. Every counter pair is `*_started` / `*_ended`: -`announces_started` / `announces_ended`, `broadcasts_started` / -`broadcasts_ended`, `subscriptions_started` / `subscriptions_ended`, and -`sessions_started` / `sessions_ended`. A live count is started minus ended. -This release also writes the previous `announced` / `*_closed` spellings beside -the new names so an older consumer still reads a new relay; a new consumer -accepts either spelling, with the canonical name winning. Payload counters -(bytes, frames, groups, datagrams) are unchanged. Traffic is split by an -arbitrary **tier** label chosen by the auth server's grant or `--cluster-tier`, -which is what makes billing per customer or per region possible. Read them with -the [`moq-stats`](https://docs.rs/moq-stats) crate. +Each node publishes `publisher.json`, `subscriber.json`, and `sessions.json` +tracks (plus compressed `.json.z` twins) of cumulative counters per broadcast +and auth root, split by a **tier** label chosen by the auth server's grant or +`--cluster-tier`, which is what makes billing per customer or per region +possible. [Stats](/concept/stats) describes the paths, tracks, and encodings; +read them with the [`moq-stats`](https://docs.rs/moq-stats) crate. ## \[iroh] diff --git a/doc/bin/rtmp.md b/doc/bin/rtmp.md index da3b3e3e8d..dee1c0f39d 100644 --- a/doc/bin/rtmp.md +++ b/doc/bin/rtmp.md @@ -30,6 +30,6 @@ publish or play request to accept, map to a path, or reject. The CLI listener is unauthenticated; firewall it. Implemented in pure Rust (no librtmp). The CLI speaks plaintext `rtmp://` -only; the library adds RTMPS when the embedder supplies a TLS config. FLAC and -MP3 enhanced-audio payloads are dropped because hang has no catalog codec for -them. +only; the library adds RTMPS on the same port when the embedder supplies a TLS +config. FLAC and MP3 enhanced-audio payloads are dropped because hang has no +catalog codec for them. diff --git a/doc/concept/audio-jitter.md b/doc/concept/audio-jitter.md index bb316d007f..eb4c490763 100644 --- a/doc/concept/audio-jitter.md +++ b/doc/concept/audio-jitter.md @@ -441,4 +441,4 @@ import outside this directory rather than leaving it to review. longer match it, which is what stops a file being edited by hand to make a failing implementation pass. It compares rather than rewrites; regenerating is the `bun doc/concept/audio-jitter/corpus.ts` command above. It runs in -`just test` and `just check`. +`just check`. diff --git a/doc/concept/hang.md b/doc/concept/hang.md index b8623c5f49..c95fa3f2e4 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -54,7 +54,7 @@ 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 track name used to subscribe, 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. -- **Jitter.** A rendition can say how long the publisher holds a frame before flushing it, in whole milliseconds rounded up: one frame for a track flushed immediately, the B-frame depth for a reordered one, the fragment for a segmented one. 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. +- **Jitter.** A rendition can say how far its frames fell behind the media clock before the publisher flushed them, in whole milliseconds rounded up. Encoders report the spread of lateness above each rendition's own recent minimum, so a constant encoder delay is not jitter; container imports estimate batch spans without counting ingest delay. 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. - **Stalled renditions.** A publisher can flag a rendition as temporarily bad so players prefer another one without the track disappearing. First-party video publishers set this flag after more than three frame intervals of source silence or encoding lag while subscribed, and clear it after three on-time completed frames or when idle. Browser and native capture poll while waiting; FLV and MPEG-TS importers observe video silence as container data arrives. The shared detector is `hang::catalog::stalled::Detector` in Rust and `Catalog.Stalled.Detector` in JavaScript. It is a playback diagnostic, not an authorization or routing signal. - **Archive.** A broadcast may advertise an `archive` entry naming its timeline track (a small index of each complete aligned segment) and, if recorded, the replay MoQ path, object-store URL, and format version. The timeline is what lets the [HLS gateway](/bin/hls) build playlists without subscribing to media. - **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and the archive index share one fixed epoch after timescale conversion. It is independent of `archive`, so a live-only publisher can expose wall-clock timing without creating a segment index. @@ -104,7 +104,8 @@ document would silently discard everything but the last payload: The rest is descriptive: `compression` (`deflate`, the same group-scoped `deflate-raw` the catalog uses), `schema` on a JSON track, `mime` on a binary -one, plus the optional `broadcast` reference. A +one, `bitrate` and `jitter` with the same meaning as for media, plus the +optional `broadcast` reference. A consumer that doesn't recognize a `mode` or `compression` ignores that track and round-trips it verbatim. @@ -112,9 +113,16 @@ In Rust the catalog owns the lifetime: `catalog.json_stream(track, config)` (or `json_snapshot` / `binary_snapshot` / `binary_stream`) writes the entry and retracts it when the producer drops. Read the config from `catalog.json.tracks` or `catalog.binary.tracks`, then pair its name and config with -`moq_mux::catalog::Entry::new` to subscribe. In the browser, read the same map, +`moq_mux::catalog::Entry::new` to subscribe. In C, `moq_publish_json_*` and +`moq_publish_binary_*` do the same, retracting on `_finish`. In the browser, read the same map, subscribe by name, and hand the track to `@moq/json` or `@moq/binary`. +An application with its own per-track fields can list a data track in its own +root section instead, flattening the JSON or binary entry beside those fields +so there is one entry per track. Name the section with a namespaced key such as +`com.example.mavlink`. A generic consumer only finds tracks in `json` and +`binary`. + ## Container The `container.kind` on each rendition says how frames are framed: diff --git a/doc/concept/index.md b/doc/concept/index.md index 5dbe3880bf..fae0751a11 100644 --- a/doc/concept/index.md +++ b/doc/concept/index.md @@ -37,5 +37,6 @@ watched at 100 ms by one viewer and 10 s by another. - [moq-lite](/concept/moq-lite): the pub/sub protocol, discovery, path patterns, subscriptions, and congestion behavior. - [hang](/concept/hang): the media catalog, containers, and how to extend both. - [Audio jitter](/concept/audio-jitter): how a receiver sizes its audio playout target from arrival timing. +- [Stats](/concept/stats): the traffic counters a relay publishes as broadcasts, and how to read them. - [Standards](/concept/standard): how this relates to the IETF moq-transport, MSF, LOC, and this project's own drafts, including [e2ee](/draft/moq-e2ee). - [Use cases](/concept/use-case/): MoQ compared with HLS/DASH, RTMP/SRT, WebRTC, and used for AI. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 1423170ed6..33930649a2 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -30,7 +30,9 @@ A dedicated ALPN selects the wire version for moq-lite 03 and newer. The legacy `moql` ALPN negotiates moq-lite 01 or 02 via `SETUP`. In moq-lite 05 and newer, each side also sends a `SETUP` message with its capabilities. Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts -14 through 22. Clients offer `moq-lite-06` first by default. +14 through 22. Clients offer `moq-lite-06` first by default. moq-lite 07 is +still in progress: it negotiates as `moq-lite-07-wip`, and only when both +sides explicitly enable it. ## Discovery @@ -63,6 +65,36 @@ stops new requests from resolving through it but leaves subscriptions already in flight alone: each track runs to its own end, the publisher's FIN or reset. moq-transport sessions behave the same when a namespace is withdrawn. +### Hidden broadcasts + +A path segment starting with `.` hides a route from discovery, the way a +dotfile hides from `ls`. A platform publishes its own broadcasts there (relay +stats under `.stats/`, cluster gossip under `.internal/`) without them turning +up in an app that lists everything and plays what it finds. Only segments +below the requested prefix count: listing the root skips `.stats/node`, but +listing `.stats` shows `node`. A `.` elsewhere in a segment (`catalog.pro`) is +part of the name. + +Hiding narrows discovery and nothing else. Subscribing to a hidden path by +name works without asking, and tokens authorize it like any other path. To +list hidden routes too, opt in per announce request: + +```rust +let announced = origin.consume().with_hidden(true).announced(); +``` + +```typescript +const announced = connection.announced(Path.Pattern.all(), { hidden: true }); +``` + +On the wire, moq-lite 07 (`moq-lite-07-wip`, opt-in only) carries the opt-in +on each announce request, and +moq-transport carries it as a `SUBSCRIBE_NAMESPACE` parameter once the peer's +`SETUP` says it understands one ([hidden](/draft/moq-hidden)). An older peer +never opts in, so it never discovers hidden routes. Rust sessions always opt in +on the wire and filter per local reader, so a relay mirrors everything and +each consumer decides. + ## Path patterns Rust's `moq_net::Pattern` and TypeScript's `Path.Pattern` from `@moq/net` diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 4ccc33e257..f2c7e9929e 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -26,6 +26,11 @@ maps everything else to "not supported" or a harmless equivalent. The [moq-lite page](/concept/moq-lite#what-moq-lite-leaves-out) lists the differences. +An IETF publisher declares the track's default priority in `SUBSCRIBE_OK` or +`PUBLISH` when that draft carries track properties. Groups without a priority +flag inherit it. If the property is absent, the IETF wire default of 128 maps +to model priority 127, where higher values are served first. + On drafts 14–19, the Rust publisher serves relative joining `FETCH` requests with offset zero for `NextObject` subscriptions. The fetch delivers the saved current-group prefix, and the subscription delivers later objects. Standalone, @@ -38,7 +43,8 @@ tracks waiting for a group that never arrives. Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, -[solicit](/draft/moq-solicit) to make announcements opt-in, and +[solicit](/draft/moq-solicit) to make announcements opt-in, +[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, and [probe](/draft/moq-probe) for bandwidth estimation. [moq-e2ee](/draft/moq-e2ee) is not a transport extension: it encrypts application payloads so relays still forward named tracks they cannot read. diff --git a/doc/concept/stats.md b/doc/concept/stats.md new file mode 100644 index 0000000000..7b6cb321ca --- /dev/null +++ b/doc/concept/stats.md @@ -0,0 +1,161 @@ +--- +title: Stats +description: The stats broadcasts a relay publishes, their tracks, and both JSON encodings +--- + +# Stats + +A relay publishes its traffic counters as ordinary MoQ broadcasts, so any +subscriber can read them: a dashboard, a billing meter, an aggregator in +another region. This page is the wire contract for those broadcasts, enough to +read them in any language. [`moq-stats`](https://docs.rs/moq-stats) is the Rust +producer and consumer, and the relay's [`[stats]`](/bin/relay/config#stats) +section turns it on. + +## Broadcasts + +Each node publishes under a prefix, `.stats` by default, which +[moq-lite](/concept/moq-lite) hides from announce listings that don't ask for +it. Traffic under the prefix is never counted, so serving stats doesn't +generate more stats. + +```text +/node/ depth 0: one broadcast per node +//node/ depth N: one broadcast per group per node +``` + +- `` tells relays sharing a cluster apart. It may span several segments + (`sjc/1`), and is omitted along with its slash when unset: `/node`. +- `` is the first `depth` segments of each broadcast path (for traffic) + or auth root (for sessions), so a consumer can scope an announce to one + tenant. A path shorter than `depth` groups under all of its segments. +- The literal `node` segment leaves room for sibling categories under the same + prefix, so a consumer skips any path without `node` where it expects one. A + group segment literally named `node` is ambiguous; don't use one. + +At depth 0 the broadcast stays announced for the producer's life. At depth +1 or more, a group's broadcast is announced while that group has entries and +unannounced once it has none. + +## Tracks + +Traffic is split by **tier**, an arbitrary label (a billing class, a region) +the relay takes from the auth grant or `--cluster-tier`. Each tier has three +tracks, each in two encodings: + +| Track | Frame keyed by | Entry | +| --- | --- | --- | +| `publisher.json` | broadcast path | [Traffic](#traffic) this node sent (egress) | +| `subscriber.json` | broadcast path | [Traffic](#traffic) this node received (ingress) | +| `sessions.json` | auth root | [Presence](#presence) of connected sessions | + +The default tier is unprefixed. A named tier prefixes each name with its +label and a slash: tier `region/sjc` publishes `region/sjc/publisher.json`. +Appending `.z` selects the [compressed](#compressed) encoding of the same +track: `publisher.json.z`. + +The default tier's six tracks always exist. A named tier's are created on its +first recorded traffic, but a subscriber may ask for them earlier: any name of +the shape `[/]{publisher,subscriber,sessions}.json[.z]` is accepted and +held open with `{}` until the tier records. Any other name is refused. + +## Frames + +Every frame is a JSON object mapping a key (broadcast path or auth root) to an +entry. An entry appears while it is **live**, meaning some started counter +still exceeds its ended counterpart so traffic could resume at any moment, and +on any tick its counters changed. Once fully closed it appears one last time +with its final counters and is then dropped. A track with no entries holds `{}`. + +The producer drains its counters every interval (one second by default) and +writes only when a track's frame changed, so silence means nothing moved, not +that the producer is gone. The track ends when the producer does, or at +depth 1 or more when its group's broadcast is unannounced; the group may +return later as a new broadcast. + +### Traffic + +```json +{ + "acme/live": { + "announces_started": 1, "announces_ended": 0, "announced_bytes": 9, + "broadcasts_started": 3, "broadcasts_ended": 1, + "subscriptions_started": 6, "subscriptions_ended": 2, + "fetches": 0, + "bytes": 1048576, "frames": 900, "groups": 30, "datagrams": 0, + "stale": { "bytes": 0, "frames": 0, "groups": 0, "datagrams": 0 }, + "announced": 1, "announced_closed": 0, + "broadcasts": 3, "broadcasts_closed": 1, + "subscriptions": 6, "subscriptions_closed": 2 + } +} +``` + +| Field | Counts | +| --- | --- | +| `announces_started` / `announces_ended` | Announces and unannounces of the broadcast. | +| `announced_bytes` | The broadcast name's length, summed over each announce and unannounce. Not part of `bytes`. | +| `broadcasts_started` / `broadcasts_ended` | On `publisher.json`, a session's first subscription to the broadcast and its last one closing. Started minus ended is the viewer count. `subscriber.json` leaves both at zero: ingress does not count viewers. | +| `subscriptions_started` / `subscriptions_ended` | Track subscriptions opened and closed. | +| `fetches` | One-shot group fetches requested, including ones that found nothing. Their payload counts in `bytes`, `frames`, and `groups`. | +| `bytes` / `frames` / `groups` | Payload delivered. | +| `datagrams` | Groups delivered as an unreliable datagram. A subset of `groups`. | +| `stale` | Payload skipped because it aged past a subscriber's latency budget, with the same four fields. Disjoint from the top-level payload counters. | + +The last six fields are legacy spellings of the `*_started` and `*_ended` +counters, still written so an older consumer reads a newer relay. A reader +should prefer the canonical name and fall back to the legacy one. + +### Presence + +```json +{ "acme": { "sessions_started": 12, "sessions_ended": 10, "sessions": 12, "sessions_closed": 10 } } +``` + +`sessions_started` and `sessions_ended` count connects and disconnects under an +auth root on the tier, whether or not any data flows. `sessions` and +`sessions_closed` are their legacy spellings. A session moved to a new tier +ends on the old one and starts on the new. + +### Counters + +Every counter is a cumulative, monotonic unsigned integer. A rate is the +difference between two frames divided by the time between them, and a live +count is started minus ended. A frame never shows ended above started. + +A counter going **down** means the relay restarted or the entry was dropped +and re-created. Treat it as the start of a fresh segment rather than a +negative rate. + +A reader ignores unknown fields, so a newer relay can add counters, and +defaults a missing field to zero, so it can read an older relay. + +## Encodings + +Both encodings carry identical frames; pick by bandwidth. Only the track name +says which one a track uses: the payload has no marker. + +### Plain + +On a `.json` track each changed frame is its own group holding one frame, the +full object as UTF-8 JSON. A reader takes the newest group. + +### Compressed + +A `.json.z` track is a [moq-json](/lib/rs/moq-json) snapshot track with +compression on. Stats frames change little between ticks, so it costs a +fraction of the plain track's bytes. + +- **Groups.** A group's first frame is the full object. Each later frame is an + [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396) merge patch against the + value so far: it carries only the changed counters, and `null` removes a + dropped entry. The producer starts a new group once the patches outgrow + eight times the snapshot's compressed size, or after 256 frames. +- **DEFLATE.** Each group's frames form one raw DEFLATE stream, sync flushed + per frame with the trailing `00 00 ff ff` stripped, as + [moq-flate](/draft/moq-flate) specifies. The window starts cold at every + group and never spans two. + +To read one, jump to the newest group, inflate and parse its first frame, +then inflate and apply each following frame as a merge patch, in order. A +reader missing a frame abandons the group and waits for the next. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index f820be3aad..7a3c5b08dd 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -40,9 +40,9 @@ and `target/include/moq.h`. - **Encoded video metadata.** `moq_video_init.hint` is a zero-initialized `moq_video_hint` with `has_*` flags for coded dimensions, bitrate (bits per second), frame rate, and latency preference. Hints seed a video codec track's catalog; detected dimensions take precedence. - **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, the Happy Eyeballs delays, `websocket_enabled`/`_delay_us`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. -- **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. +- **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge, so a cache linger never delays it. Only a relay keeps what it already delivered warm for 30 seconds, and a returning subscriber is served that cache only once the publisher confirms it is still current. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. ```c moq_client_config config; @@ -57,6 +57,8 @@ if (session < 0) return fail(moq_error()); ``` +For a locally encoded media track, call `moq_publish_media_flush(media, timestamp_us)` after `moq_publish_media_frame` with the same broadcast-clock PTS. The monotonic handoff time is sampled inside libmoq. Do not call it for file, pipe, or network imports; those remain clock-free. Invalid handles and unrepresentable timestamps return a negative error code. + ## Connection stats Every field in `moq_connection_stats` carries a matching `_valid` flag, diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index c3dfd82dae..564d4e06ed 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -71,7 +71,8 @@ every path beneath it (`''` for everything; Dart spells the origin method claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announcements(options:)` takes a literal prefix plus an optional relative pattern; `announcement.prefix()` -stays origin-relative and `captures()` reports the wildcard matches. +stays origin-relative and `captures()` reports the wildcard matches. Paths with +a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless `hidden: true`. Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `Moq.connect` and `Server.listen` take a `ConnectOptions` / @@ -106,6 +107,8 @@ catalog and container types are there, so already-encoded frames flow through `MoqMediaProducer`/`MoqMediaConsumer`, but encoding is up to `package:camera`, platform channels, or another codec package. +`MediaProducer.flush(timestampUs: ...)` records the handoff of a locally encoded frame on the broadcast media clock. Call it after `writeFrame` only for live encoder output; file, pipe, and network imports stay clock-free. `MediaProducer` aliases the generated FFI object, so its method is available directly. + ## Connection stats `session.stats()` returns a `ConnectionStats` snapshot. Each field is `null` diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 80cfb212d6..001f5a50e4 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -69,6 +69,8 @@ _ = broadcast.Announce(moq.Route{}) broadcast.Finish() // keep the producer reachable while publishing, then finish explicitly ``` +For locally encoded media, call `MediaProducer.Flush(timestampUs)` after `WriteFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `Flush`; built-in encoders observe their own output. + The three advertising operations: `client.CreateBroadcast(path)` (or `origin.CreateBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.Announce(route)` / `broadcast.Unannounce()` own that exact-path @@ -78,6 +80,8 @@ while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `Announced(options)` combines a literal prefix with an optional relative pattern; `ann.Prefix()` stays relative to the origin and `ann.Captures()` reports the wildcard matches. +Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless +`Hidden: true`. Every call that can block takes a `context.Context` first. Cancelling it returns `ctx.Err()` promptly and tears the in-flight native work down, so a diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 0ea3b7c819..f086c11cd9 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -52,6 +52,7 @@ for (;;) { - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. +- **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. @@ -104,7 +105,9 @@ match (otherwise `undefined`), `kind` is `"announced"`, `"updated"` (a reprice in place), or `"retracted"`, and `route` carries hops and cost (on a retraction, its last values). The consumer is an async iterable. A prefix is not a broadcast name; the scope filters locally while sessions request its -literal head on the wire. +literal head on the wire. Paths with a `.`-prefixed segment below that head +are [hidden](/concept/moq-lite#hidden-broadcasts) unless `announced(scope, { hidden: true })` opts in; +`broadcasts(scope, { hidden: true })` takes the same option. Examples in [`js/net/examples/`](https://github.com/moq-dev/moq/tree/main/js/net/examples). diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 2af452cb20..6c9c1dd0c5 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -54,6 +54,21 @@ framerate, and bitrate are tunable through `el.video.config`; the audio encoder exposes its codec and volume. For simulcast or several renditions, drop the element and register your own encoders on a `Publish.Broadcast`. +The video and audio encoders measure how far their output falls behind the media +clock when they flush frames. Catalog jitter is the spread above each +rendition's own recent minimum lateness, so a constant encoder delay is not jitter. +The advertised value only rises; frame duration alone does not set it. + +## Clock + +Every timestamp the publisher writes is `performance.now()` in microseconds, +so camera, microphone, screen, and file sources share one timeline. The catalog +advertises that mapping as its root `clock` from the first snapshot, with PTS +zero at `performance.timeOrigin`, so a viewer or an HLS export can name any +frame's wall time. The mapping is fixed for the page: a system-clock +adjustment never retimes the broadcast. Stamp your own tracks (e.g. text cues) +on the same timeline to stay in sync. + ## Custom tracks `broadcast.net` is the underlying `Moq.Broadcast.Producer`, so an application @@ -120,7 +135,9 @@ new Publish.Audio.Encoder("audio", { broadcast, capture: audioCapture, enabled: Standalone components start enabled unless you pass `enabled: false` (or a signal). Camera and microphone sources may prompt for permission on construction, so build an enabled screen source inside the user gesture that -authorizes screen capture. +authorizes screen capture. Audio capture that starts before the page's first +click or keypress waits for one: browsers suspend Web Audio until then, and the +audio rendition stays out of the catalog until samples flow. Every input and output is a signal from [`@moq/signals`](/lib/js/signals). Load from a CDN (`https://esm.sh/@moq/publish/element`) for a no-build embed. diff --git a/doc/lib/js/signals.md b/doc/lib/js/signals.md index 515db35a9a..54b59838f6 100644 --- a/doc/lib/js/signals.md +++ b/doc/lib/js/signals.md @@ -38,7 +38,8 @@ The rules that differ from other signal libraries: - **Nothing is tracked implicitly.** `effect.get(signal)` subscribes; `signal.peek()` doesn't. - **Writes coalesce per microtask** and only notify on a real change (deep for plain objects, identity for class instances). -- **Effects own their resources.** `effect.timer`, `interval`, `animate`, `event`, `spawn`, and `run` (a nested effect) all clean up on rerun or close, so never call `setTimeout` or `addEventListener` inside one directly. A rerun waits for the previous run's `spawn` tasks to settle, and `effect.abort`/`effect.cancel` tell them to stop. +- **Effects own their resources.** `effect.timer`, `interval`, `animate`, `event`, `spawn`, and `run` (a nested effect) all clean up on rerun or close, so never call `setTimeout` or `addEventListener` inside one directly. A rerun waits for the previous run's `spawn` tasks to settle, and `effect.abort`/`effect.race` tell them to stop. +- **Race with `race`, not `Promise.race`.** `Promise.race` leaves a listener on every value that loses, so racing a long-lived one (a `closed`, a run's teardown) once per frame grows the heap. `race([...])` accepts promises and `Once` values and drops its listeners when it settles; `effect.race(promise)` also resolves `undefined` once the run is torn down. - **Dev builds warn** about effects that tracked nothing, effects garbage-collected without `close()`, and signals leaking subscribers. Components follow one shape: `in` (wired inputs), `out` (read-only derived diff --git a/doc/lib/js/watch.md b/doc/lib/js/watch.md index 6a5a79fee3..5ee0252dfa 100644 --- a/doc/lib/js/watch.md +++ b/doc/lib/js/watch.md @@ -106,7 +106,7 @@ const dispose = el.signals.run((effect) => { const consumer = new Json.Snapshot.Consumer({ track }); effect.spawn(async () => { for (;;) { - const value = await Promise.race([effect.cancel, consumer.next()]); + const value = await effect.race(consumer.next()); if (value === undefined) break; console.log("metadata", value); } diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 21c2b7953a..a241ad6b0c 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -51,6 +51,8 @@ Moq.connect("https://relay.example.com").use { moq -> } ``` +`MediaProducer.flush(timestampUs)` records a locally encoded frame's transport handoff on the broadcast media clock. Call it after `writeFrame` for live encoder output; omit it for file, pipe, and network imports. `MediaProducer` is a typealias, so the generated method is available directly. + The three advertising operations: `moq.createBroadcast(path)` (or `origin.createBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route)` / `broadcast.unannounce()` own that exact-path @@ -59,7 +61,8 @@ path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announcements(config)` takes a literal prefix plus an optional relative pattern; `announcement.prefix()` -stays origin-relative and `captures()` reports the wildcard matches. +stays origin-relative and `captures()` reports the wildcard matches. Paths with +a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless `hidden = true`. Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `moq.epoch()` counts the connections, 1 on the first, pairing with diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 94b79d7646..a41cb7255b 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -69,6 +69,8 @@ async def main(): asyncio.run(main()) ``` +For already-encoded live output, call `audio.flush(timestamp_us)` after each `audio.write_frame` with the same broadcast-clock PTS. It samples the transport handoff for catalog jitter. File, pipe, and network imports should omit `flush`; raw-pixel and PCM encoders inside the binding measure their own output. + The three advertising operations, as the other bindings spell them: `client.create_broadcast(path)` (or `OriginProducer.create_broadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route)` / @@ -79,6 +81,8 @@ advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announced(prefix, filter=...)` combines a literal root with an optional relative pattern; each announcement `.prefix` stays relative to the origin and `.captures` reports what the pattern wildcards matched. +Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless +`hidden=True`. Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `session.epoch()` counts the connections, 1 on the first, pairing diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index fa08f1debf..a3fd8d42e8 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -38,11 +38,47 @@ Each catalog track constructor returns one `container::Producer` that owns the media stream and its catalog entry. `set` publishes or replaces its config, `modify` edits the published config through a guard, and dropping the producer retires the entry. Calling `modify` before the first `set` returns -`Error::NotPublished`. Container writes measure bitrate and jitter and publish -the estimate automatically when groups are cut or finished. Invalid jitter is -rejected before the edit is retained, including while the initial catalog is -reserved. Codec importers propagate catalog and media errors through their -configuration and frame-writing methods. +`Error::NotPublished`. Container writes measure bitrate; importers can also +measure batch span or reorder delay for jitter. Locally encoded frames call +`container::Producer::flush(timestamp, Instant::now())`; jitter is the spread +above that track's own recent minimum lateness, published as soon as it rises. +Generic imports remain clock-free. Invalid or decreasing jitter is rejected +before the edit is retained, including while the initial catalog is reserved. +Codec importers propagate catalog and media errors through their configuration +and frame-writing methods. + +Data tracks go through the catalog too. `catalog.json_stream(track, config)` +(or `json_snapshot`, `binary_snapshot`, `binary_stream`) writes the track's +`json` or `binary` entry, measures an absent `bitrate` from the writes, and +retires the entry when the producer drops. To list the track in your own +section beside application fields, pass that section's entry instead of a +`json::Config` or `binary::Config`: any `RenditionConfig` that embeds the data +config through `AsMut`. + +```rust +#[derive(Serialize, Deserialize, Clone)] +struct Mavlink { + #[serde(flatten)] + binary: hang::catalog::BinaryConfig, // mode, compression, bitrate, ... + sysid: u8, +} + +impl AsMut for Mavlink { + fn as_mut(&mut self) -> &mut hang::catalog::BinaryConfig { + &mut self.binary + } +} + +// Plus `RenditionConfig` writing to `catalog.ext.mavlink`, a map +// serialized under the `com.example.mavlink` root key. +let binary = hang::catalog::BinaryConfig::new(hang::catalog::Mode::Stream); +let mut telemetry = catalog.binary_stream(track, Mavlink { binary, sysid: 1 })?; +telemetry.append(packet)?; +``` + +The producer sets the entry's `mode` and encodes the track with its +`compression`. Read it back from `Catalog` and subscribe with +`catalog::Entry::new(name, &entry.binary)`. ```bash cargo add moq-mux diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 0a3dd1b1bc..2f56c399b6 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -135,7 +135,9 @@ most specific matching scope member's wildcards stood for when the prefix pins them, and `route` carries hops and cost (on a retraction, its last values). The consumer is also a `futures::Stream`. A prefix is not a broadcast name; sessions request each scope member's literal head and filter -locally. +locally. Routes with a `.`-prefixed segment below that head are [hidden](/concept/moq-lite#hidden-broadcasts) +unless `with_hidden(true)` opts the consumer in. Sessions always ask the peer +for hidden routes, so each local consumer decides. ## Limiting reads diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 9a205d8405..a9aa8b3c44 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -55,6 +55,8 @@ try broadcast.announce() session.shutdown() ``` +For already-encoded live output, call `audio.flush(timestampUs:)` after `writeFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `flush`; built-in encoders observe their own output. + The three advertising operations: `session.publish.createBroadcast(path:)` returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / `broadcast.unannounce()` own that exact-path advertisement; @@ -64,6 +66,8 @@ claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announced(prefix:filter:)` combines a literal root with an optional relative pattern; `announcement.prefix` stays relative to the origin and `captures` reports what the wildcards matched. +Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless +`hidden: true`. For a self-signed relay on your own test network, `try client.setTlsVerify(false)` accepts any certificate; prefer `setTlsRoots` or a fingerprint anywhere else. diff --git a/doc/setup/dev.md b/doc/setup/dev.md index 9aaf57bad6..b7a3de2ffa 100644 --- a/doc/setup/dev.md +++ b/doc/setup/dev.md @@ -14,9 +14,8 @@ match CI. | `just` | Start the local relay, test publisher, and web demo. | | `just --list` | List every recipe. | | `just fix` | Format and lint the packages this branch changed. | -| `just check` | Compile and lint the same scope. This is what CI runs. | -| `just test` | Run tests for the same scope. | -| `just fix --all`, `just check --all`, `just test all` | The same, over every package. | +| `just check` | Compile, lint, and test the same scope. This is what CI runs. | +| `just fix --all`, `just check --all` | The same, over every package. | | `just pub bbb ` | Publish Big Buck Bunny (also `tos`, `clock`, `gst`, `hls`). | | `just sub gst bbb ` | Play a broadcast through GStreamer. | | `just relay` | Run a local relay on its own. | @@ -60,7 +59,6 @@ taskkill /IM moq.exe /F ```bash just fix just check -just test ``` See [CONTRIBUTING.md](https://github.com/moq-dev/moq/blob/main/CONTRIBUTING.md) diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index a3a574a8f7..30d711e3c9 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -134,6 +134,7 @@ type Catalog = { ~~~ Additional fields MAY be added based on the application. +An application SHOULD name its own root sections with a namespaced key, such as a reverse-DNS name (`com.example.telemetry`), so they cannot collide with a section a later version of this specification defines. The catalog SHOULD be mostly static, delegating any dynamic content to other tracks. For example, a chat entry should name a chat track, not carry individual chat messages. @@ -388,6 +389,8 @@ type JsonSchema = { "compression": Compression | undefined, "schema": string | undefined, "broadcast": string | undefined, + "bitrate": number | undefined, + "jitter": number | undefined, } ~~~ @@ -401,6 +404,8 @@ type BinarySchema = { "compression": Compression | undefined, "mime": string | undefined, "broadcast": string | undefined, + "bitrate": number | undefined, + "jitter": number | undefined, } ~~~ @@ -456,6 +461,10 @@ A `snapshot` group covers a single value (plus any deltas), so its window spans ### broadcast {#data-shared} The `broadcast` field carries the same meaning here as it does for a media rendition ({{field-broadcast}}). +### bitrate and jitter {#data-estimates} +The optional `bitrate` field is the track's maximum bitrate in bits per second. +The optional `jitter` field carries the same meaning and rules as it does for a media rendition ({{field-jitter}}), with a payload in place of a frame. + ## Binary Fields {#binary} A decoder config field carrying raw bytes, notably `description` (an `AllowSharedBufferSource` in WebCodecs), is carried in the catalog as a hex string ({{!RFC4648, Section 8}}). A publisher SHOULD emit lowercase hexadecimal characters and MUST NOT emit a `0x` prefix or any separators. @@ -502,13 +511,14 @@ The container used to frame this rendition's media, as described in {{container} If absent, it defaults to `{ "kind": "legacy" }`. ### jitter {#field-jitter} -The maximum delay, in milliseconds, between a frame being ready and the publisher flushing it. +The maximum delay, in milliseconds, that a publisher has measured before handing a frame to the transport. A consumer's jitter buffer SHOULD be at least this large to avoid stalling. -If absent, a consumer SHOULD assume each frame is flushed immediately. +If absent, a consumer SHOULD assume no publisher delay has been measured. -It is measured at the publisher: how far behind the media clock a frame is when the publisher hands it to the transport, whether an encoder, a reorder buffer, or a segmenter held it. -An importer can estimate this delay from the media span of a batch, such as a run of audio PES packets between video packets in TS or an fMP4 fragment, without measuring the time spent waiting for input. -It is never a measurement of the network, which a consumer observes for itself and which no two consumers of the same broadcast would agree on. +An encoder compares each frame's flush time with its media timestamp and subtracts the smallest lateness recently observed on the same rendition. +The field is the spread above that minimum, so neither a constant encoder delay, a fixed clock offset, nor long-term drift counts as jitter. +A container importer instead estimates the delay from the media span of a batch, such as a run of audio PES packets between video packets in TS or an fMP4 fragment, without measuring the time spent waiting for input. +A publisher does not count ingest network delay in this field; a consumer measures network delay separately. A publisher MUST round the value up to a whole number of milliseconds, so a consumer sizing a buffer against it is never handed a bound below the real one. A publisher MUST NOT advertise `0`; a track that flushes each frame immediately omits the field instead. @@ -517,13 +527,10 @@ A publisher MUST NOT lower a previously advertised value, since a burst it emitt For example: -- If each frame is flushed immediately, a video track's `jitter` is `1000/framerate` rounded up: 34 at 30 fps. -- If up to 3 B-frames may be emitted in a row, it is `3 * 1000/framerate`. -- If frames are buffered into 2 second segments, it is `2000`. -- If frames are flushed several at a time, it is the media span of the whole burst, not of one frame. - -An audio frame's duration is codec dependent. -AAC often uses 1024 samples per frame, so at 44100Hz an immediately-flushed track's `jitter` is 24. +- A frame flushed without extra delay contributes no `jitter`, regardless of frame rate. +- An encoder that consistently flushes 200 milliseconds late contributes no `jitter`; only variation above its own minimum counts. +- A fragment or packet batch contributes the media span between its earliest timestamp and flush point. +- Reordered frames contribute the delay they were held before flushing, without treating a decode-order presentation timestamp gap as delay by itself. # Container {#container} Audio, video, and text tracks use a container to encapsulate the media payload. @@ -1060,9 +1067,10 @@ This document has no IANA actions. ## moq-hang-03 {:numbered="false"} +- Defined encoder `jitter` as flush lateness above the rendition's own recent minimum, replacing fixed frame-duration hints; container batches retain media-span estimates. - Clarified that CMAF audio samples are sync samples independently of publisher group boundaries. - Clarified that container importers can estimate jitter from batch media spans without measuring input wait time. -- Specified the `jitter` field's computation: the publisher's own structure rather than the network, rounded up to whole milliseconds, never `0` (a consumer treats `0` as absent), and never lowered once advertised. The 30 fps and 44.1 kHz AAC examples became 34 and 24. +- Specified the `jitter` field's computation: the publisher's own structure rather than the network, rounded up to whole milliseconds, never `0` (a consumer treats `0` as absent), and never lowered once advertised. - For video, an empty codec payload is the exclusive end of the frame before it. A video publisher SHOULD end each group with one when the exclusive end is known. Audio retains its terminal-trimming marker before codec drain packets. A publisher MAY estimate an unknown final duration from the frame cadence, but MUST NOT use batching or reorder delay as that duration. A consumer skips it and does not submit it to a decoder. Audio terminal-packet trimming is unchanged. - Specified version 1 recording objects: JSON track properties and binary group/frame tables with ascending, delta-encoded group sequences. @@ -1076,6 +1084,8 @@ A publisher MAY estimate an unknown final duration from the frame cadence, but M - A publisher that stops producing and may resume on the same track SHOULD publish a discontinuity marker when it stops. - An audio endpoint bounds only the terminal packets that follow it in its own group. - Replaced the archive timeline `wall` field with a root `clock` section (`wall` plus `timescale`): one fixed broadcast mapping every track and the archive index convert into, independent of any archive. Zero timescales and walls past the JSON-safe integer range are refused. +- Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. +- Recommended namespaced keys for application root sections. # Acknowledgments {:numbered="false"} diff --git a/drafts/draft-lcurley-moq-hidden.md b/drafts/draft-lcurley-moq-hidden.md new file mode 100644 index 0000000000..ee1c14720e --- /dev/null +++ b/drafts/draft-lcurley-moq-hidden.md @@ -0,0 +1,139 @@ +--- +title: "MoQ Hidden Extension" +abbrev: "moq-hidden" +category: info + +docname: draft-lcurley-moq-hidden-latest +submissiontype: IETF # also: "independent", "editorial", "IAB", or "IRTF" +number: +date: +v: 3 +area: wit +workgroup: moq + +author: + - + fullname: Luke Curley + email: kixelated@gmail.com + +normative: + moqt: I-D.ietf-moq-transport + +informative: + +--- abstract + +This document defines an extension for MoQ Transport {{moqt}} that hides namespaces from discovery. +A namespace with a field starting with `.` below the prefix a subscriber asked for is left out of the advertisements it receives, unless its SUBSCRIBE_NAMESPACE opts in. +A platform can then add internal namespaces, such as statistics, without them turning up in applications that list everything and use what they find. + +--- note_Note_to_Readers + +This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. +Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. + +--- middle + +# Conventions and Definitions +{::boilerplate bcp14-tagged} + +An endpoint **advertises** a namespace by sending PUBLISH_NAMESPACE, or NAMESPACE in response to a SUBSCRIBE_NAMESPACE. + +A namespace is **hidden** from a subscription when one of its fields beyond the subscription's Track Namespace Prefix starts with the byte 0x2E (`.`). +A field inside the prefix never hides anything, so a prefix that names the hidden field itself lists what is under it, and a namespace at or above the prefix has no field beyond it. +Only the first byte counts: `catalog.v2` is not hidden. + + +# Introduction +Discovery in {{moqt}} is all or nothing: a subscriber that asks for a prefix is told every namespace beneath it. +An application that asks for the empty prefix and plays what it finds breaks the moment its platform publishes anything else under the same root, like a relay's own statistics or internal routing state. + +This extension reserves a leading `.` for such namespaces, as file systems do for hidden files. +A hidden namespace is still published, routed, and subscribed to like any other; it is only left out of discovery by default. +A subscriber that wants hidden namespaces too says so on the SUBSCRIBE_NAMESPACE that would list them. + +The name alone decides: there is no publisher-side flag, so a namespace cannot be hidden from one subscriber and listed to another under the same prefix. + + +# Setup Negotiation + +An endpoint declares that it understands the HIDDEN parameter ({{parameter}}) with the following Setup Option ({{moqt}} Section 10.3): + +~~~ +HIDDEN Setup Option { + Option Key (vi64) = 0x40B5C + Option Value (vi64) = 1 +} +~~~ + +A receiver MUST ignore the value. +An endpoint MUST NOT send the HIDDEN parameter to a peer that did not declare this option, because an unknown parameter fails decoding. +A subscriber that wants hidden namespaces therefore waits for the peer's SETUP before sending SUBSCRIBE_NAMESPACE. + +The rest of this extension applies whether or not the peer declared the option: a peer that never heard of it never opts in, so it is never told about hidden namespaces. + + +# Opting In {#parameter} + +A subscriber opts in to hidden namespaces with the following parameter on SUBSCRIBE_NAMESPACE: + +~~~ +HIDDEN Parameter { + Type (vi64) = 0x40B5E + Value (vi64) = 0 or 1 +} +~~~ + +A value of 1 opts in; 0 or an absent parameter does not. +A receiver MUST close the session with a PROTOCOL_VIOLATION on any other value. + + +# Advertising {#advertising} + +A publisher SHOULD NOT advertise a hidden namespace in response to a SUBSCRIBE_NAMESPACE that did not opt in. +Hiding is a convenience for discovery, not access control, so a publisher MAY treat a subscriber it trusts, such as another relay in its own cluster, as opted in. + +An unsolicited PUBLISH_NAMESPACE answers no prefix, so it is measured against the empty one: a publisher SHOULD NOT send one for a hidden namespace. +When unsolicited advertisements are live, a SUBSCRIBE_NAMESPACE is answered with only the namespaces they left out, which is to say those hidden from the empty prefix, that the subscription may see. +That covers both an opt-in and a prefix that names a hidden field itself, and no namespace is advertised twice. + +Hiding narrows discovery and nothing else. +A SUBSCRIBE, FETCH, or TRACK_STATUS for a track in a hidden namespace is served exactly as it would be without this extension. + + +# Security Considerations + +A hidden namespace is not a secret. +Anyone who learns its name can subscribe to it, and a subscriber can opt in at will, so a publisher MUST apply the same authorization to hidden namespaces as to any other. + + +# IANA Considerations + +This document requests the following registrations. +High, distinctive values are requested to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions. + +## MOQT Setup Options + +This document requests one registration in the "MOQT Setup Options" registry ({{moqt}} Section 15.4), whose policy is Specification Required. + +| Value | Name | Reference | +|:--------|:-------|:--------------| +| 0x40B5C | HIDDEN | This Document | + +## MOQT Message Parameters + +This document requests one registration in the "MOQT Message Parameters" registry ({{moqt}} Section 15.7). + +| Value | Name | Carried In | Reference | +|:--------|:-------|:--------------------|:--------------| +| 0x40B5E | HIDDEN | SUBSCRIBE_NAMESPACE | This Document | + +Both values are even, so each is a bare varint. + + +--- back + +# Acknowledgments +{:numbered="false"} + +This document was drafted with the assistance of Claude, an AI assistant by Anthropic. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index dcac539e93..455a587917 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -94,7 +94,7 @@ A Session consists of a connection between a client and a server. There is currently no P2P support within QUIC so it's out of scope for moq-lite. The moq-lite version identifier is `moq-lite-xx` where `xx` is the two-digit draft version. -The identifier for this draft is `moq-lite-06`. +The identifier for this draft is `moq-lite-07-wip` while it is a work in progress, and becomes `moq-lite-07` once finalized. For bare QUIC, this is negotiated as an ALPN token during the QUIC handshake. For WebTransport over HTTP/3, the QUIC ALPN remains `h3` and the moq-lite version is advertised via the `WT-Available-Protocols` and `WT-Protocol` CONNECT headers. @@ -383,6 +383,15 @@ A route covers a path when its prefix is a leading run of the path's segments; m 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. +#### 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. +Only the first byte counts: `catalog.v2` is not hidden. + +A publisher SHOULD NOT announce a hidden route 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 Warm and Cold Route Cost (see [ANNOUNCE_START](#announce-start)), which relays use to build a loop-free mesh. @@ -801,12 +810,17 @@ A subscriber sends an ANNOUNCE_REQUEST message to indicate it wants to receive a ANNOUNCE_REQUEST Message { Message Length (i) Broadcast Path Prefix (s), + Hidden (8), } ~~~ **Broadcast Path Prefix**: 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. +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, and ANNOUNCE_UPDATE messages for any future updates, subject to [Routing](#routing). Implementations SHOULD consider reasonable limits on the number of matching broadcasts to prevent resource exhaustion. @@ -1314,6 +1328,11 @@ The `Message Length` describes the payload size on the wire. # Appendix A: Changelog +## moq-lite-07 + +- Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. +- Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. + ## moq-lite-06 - Assigned `moq-lite-06` as this draft's protocol identifier. diff --git a/flake.nix b/flake.nix index 9cee6d70e4..028422fd1b 100644 --- a/flake.nix +++ b/flake.nix @@ -304,17 +304,18 @@ ]; # uniffi-bindgen-dart renders rs/moq-ffi into dart/moq_ffi. The fork - # carries the uniffi 0.32 port and library-mode CLI while those changes - # remain open upstream. + # carries the uniffi 0.32 port, library-mode CLI, and RustBuffer leak + # fixes while those changes remain open upstream. Its tags add a + # `-kixelated.N` pre-release so they never collide with upstream's. uniffi-bindgen-dart = pkgs.rustPlatform.buildRustPackage rec { pname = "uniffi-bindgen-dart"; - version = "0.3.0+v0.32.0"; + version = "0.3.1-kixelated.4+v0.32.0"; src = pkgs.fetchFromGitHub { owner = "kixelated"; repo = "uniffi-dart"; rev = "v${version}"; - hash = "sha256-jvVEZVZLorj+GPUXL6Y4riCLsbJcWWbQgIIUoK/ZSEo="; + hash = "sha256-BCIooajAp0Wqt7LeanFSdmS/GT0uYo+d8Qv2jGWCJD8="; }; # The upstream repository ignores Cargo.lock so cargo installs test diff --git a/go/scripts/stage.sh b/go/scripts/stage.sh index ca72d92c67..3fcde90410 100755 --- a/go/scripts/stage.sh +++ b/go/scripts/stage.sh @@ -63,8 +63,8 @@ command -v uniffi-bindgen-go >/dev/null 2>&1 || { HOST_TARGET=$(rustc -vV | awk '/^host:/ {print $2}') # Debug by default. This is a compile-and-test gate, not a benchmark, and a -# release build of moq-ffi shares no artifacts with the debug ones `just check` -# and `just test` already produce, so it was a third full compile of the +# release build of moq-ffi shares no artifacts with the debug ones `just ci check` +# and `just ci test` already produce, so it was a third full compile of the # dependency tree (~5 min of CI on its own, plus a whole target/release tree on # a runner that was already tight on disk). Set MOQ_FFI_PROFILE=release for an # optimized cdylib; the shipped artifacts are built by rs/moq-ffi/build.sh, diff --git a/go/wrapper/README.md b/go/wrapper/README.md index 52ed3ce69d..cf241671b5 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -77,6 +77,10 @@ for media tracks whose timescale should be selected by the importer. `WithVideoHint(moq.VideoHint{...})` for video catalog fields that are known before the stream reveals them. +`WithAudioTrack(name)` / `WithVideoTrack(name)` name the track instead of +deriving a unique name from the format. A duplicate name fails, and the +`OnTrack` variants refuse it because the request already names the track. + JSON tracks are available in two modes. `PublishJSONSnapshot` / `SubscribeJSONSnapshot` carry lossy latest state, while `PublishJSONStream` / `SubscribeJSONStream` carry every record in order. Producers accept any `encoding/json` value; consumers return diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index cb5c1915f0..67eb77e040 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -125,6 +125,8 @@ type AnnounceOptions struct { Prefix string // Filter is a pattern relative to Prefix. Nil matches every path beneath it. Filter *string + // Hidden also lists paths with a segment starting with "." below Prefix. + Hidden bool } // Announced streams routes under a literal prefix matching an optional pattern filter. @@ -132,6 +134,7 @@ func (o *OriginConsumer) Announced(options AnnounceOptions) (*AnnounceConsumer, inner, err := o.inner.Announced(ffi.MoqAnnounceConfig{ Prefix: options.Prefix, Filter: options.Filter, + Hidden: options.Hidden, }) if err != nil { return nil, err diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 29ef6ad19c..6eccc6d8f6 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -30,6 +30,22 @@ func WithVideoLabel(label string) VideoOption { } } +// WithAudioTrack names the track instead of deriving a unique name from the +// format. A requested track already has a name, so the OnTrack variant refuses it. +func WithAudioTrack(track string) AudioOption { + return func(init *ffi.MoqAudioInit) { + init.Track = &track + } +} + +// WithVideoTrack names the track instead of deriving a unique name from the +// format. A requested track already has a name, so the OnTrack variant refuses it. +func WithVideoTrack(track string) VideoOption { + return func(init *ffi.MoqVideoInit) { + init.Track = &track + } +} + // WithVideoHint seeds catalog fields that a video stream cannot reveal itself. func WithVideoHint(hint VideoHint) VideoOption { return func(init *ffi.MoqVideoInit) { @@ -343,6 +359,12 @@ func (m *MediaProducer) WriteFrame(frame Frame) error { return m.inner.WriteFrame(frame) } +// Flush records a local encoder's frame handoff on the broadcast media clock. +// Call after WriteFrame only for local encoder output, not file or network imports. +func (m *MediaProducer) Flush(timestampUs uint64) error { + return m.inner.Flush(timestampUs) +} + // Cut draws a group boundary here. // // Audio has no boundary of its own (every packet is independently decodable), so this is diff --git a/js/CLAUDE.md b/js/CLAUDE.md index c565e540eb..9e0bf43c2a 100644 --- a/js/CLAUDE.md +++ b/js/CLAUDE.md @@ -13,6 +13,7 @@ The spine of the JS code; read `signals/src/index.ts` before touching reactive c - `Signal` writes coalesce per microtask and notify only on change. Equality is deep for plain data but identity for class instances; `set(v, true)` forces a notify. `peek` reads without subscribing. - `Computed`: derived, `undefined` until first run and after `close()`. Standalone ones must be closed; `effect.computed()` closes with its parent. - `Effect`: reruns when a signal read via `effect.get(signal)` changes. Register teardown with `effect.cleanup(fn)`; it runs before the next run and on `close()`. A rerun waits for every `effect.spawn` task from the previous run to settle, so register teardown unconditionally. +- Never `Promise.race` a value that outlives the call, such as a `closed`; use `race` or `effect.race`, which release their listeners. - Use the scoped helpers (`effect.interval`, `timer`, `timeout`, `animate`, `event`, `subscribe`, `set`, `proxy`, `run`) instead of raw timers or listeners, so cleanup is automatic. Prefer nested `effect.run` over one giant effect. # Producer / consumer diff --git a/js/binary/package.json b/js/binary/package.json index 499de5a656..849e5f012a 100644 --- a/js/binary/package.json +++ b/js/binary/package.json @@ -21,7 +21,8 @@ }, "dependencies": { "@moq/flate": "workspace:^", - "@moq/net": "workspace:^" + "@moq/net": "workspace:^", + "@moq/signals": "workspace:^" }, "devDependencies": { "@types/bun": "^1.4.2", diff --git a/js/binary/src/stream/consumer.ts b/js/binary/src/stream/consumer.ts index 47d9ee635d..f4cc5cf441 100644 --- a/js/binary/src/stream/consumer.ts +++ b/js/binary/src/stream/consumer.ts @@ -1,5 +1,6 @@ import { Decoder as Flate } from "@moq/flate"; import type * as Moq from "@moq/net"; +import { race } from "@moq/signals"; import { isDeflate } from "../compression.ts"; import type { Config as CodecConfig } from "./producer.ts"; @@ -102,7 +103,7 @@ export class Consumer { if (buffered) return buffered; const frame = group.readFrame(); - const winner = await Promise.race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); + const winner = await race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); if ("frame" in winner) return winner.frame; if (winner.group) { diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index b1f547c3a3..4b21ace239 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { DEFAULT_MAX_FRAME_SIZE } from "@moq/flate"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -146,3 +146,42 @@ test("an undecodable payload ends the log for a reader already inside the group" // Surfaces the terminal error rather than hanging on the still-open group. await expect(consumer.next()).rejects.toThrow("limit"); }); + +// Counts the reactions `run` attaches to promises still pending once it returns. A promise holds each +// reaction until it settles, so one left per iteration on a promise that outlives the loop is a leak. +// Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + +// A blocked read races the frame against the track's next group, which stays pending for the whole +// log. Racing it per payload must not leave a reaction behind on it each time. +test("blocked reads leave nothing behind on the pending group read", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const subscriber = track.subscribe(); + const consumer = new Consumer({ track: subscriber }); + + const reactions = await pendingReactions(async () => { + for (let n = 0; n < 1000; n++) { + const next = consumer.next(); + producer.append(new Uint8Array([n & 0xff])); + expect((await next)?.[0]).toBe(n & 0xff); + } + }); + expect(reactions).toBeLessThan(10); + + subscriber.close(); + producer.finish(); +}); diff --git a/js/hang/src/catalog/binary.ts b/js/hang/src/catalog/binary.ts index 2c209e1413..593eaf24d5 100644 --- a/js/hang/src/catalog/binary.ts +++ b/js/hang/src/catalog/binary.ts @@ -1,5 +1,6 @@ import * as z from "zod/mini"; import { CompressionSchema } from "./compression"; +import { u53Schema } from "./integers"; import { ModeSchema } from "./mode"; import { RelativeBroadcastSchema } from "./path"; @@ -28,6 +29,18 @@ export const BinaryConfigSchema = z.looseObject({ // An optional media type for each payload (e.g. "image/jpeg"). Purely descriptive: // a consumer that doesn't recognize it can still read the track. mime: z.optional(z.string()), + + // The maximum bitrate of the track in bits per second, if known. + bitrate: z.optional(u53Schema), + + // The maximum delay between a payload being ready and the publisher flushing it, in whole + // milliseconds rounded up, with the same meaning as a video rendition's `jitter`. + jitter: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/json.ts b/js/hang/src/catalog/json.ts index 6ee22e78a0..c5900559d7 100644 --- a/js/hang/src/catalog/json.ts +++ b/js/hang/src/catalog/json.ts @@ -1,5 +1,6 @@ import * as z from "zod/mini"; import { CompressionSchema } from "./compression"; +import { u53Schema } from "./integers"; import { ModeSchema } from "./mode"; import { RelativeBroadcastSchema } from "./path"; @@ -27,6 +28,18 @@ export const JsonConfigSchema = z.looseObject({ // An optional identifier for the shape of each value, typically a JSON Schema URL. // Purely descriptive: a consumer that doesn't recognize it can still read the track. schema: z.optional(z.string()), + + // The maximum bitrate of the track in bits per second, if known. + bitrate: z.optional(u53Schema), + + // The maximum delay between a payload being ready and the publisher flushing it, in whole + // milliseconds rounded up, with the same meaning as a video rendition's `jitter`. + jitter: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/text.ts b/js/hang/src/catalog/text.ts index a8330ab028..3bd4e3dc21 100644 --- a/js/hang/src/catalog/text.ts +++ b/js/hang/src/catalog/text.ts @@ -52,7 +52,12 @@ export const TextConfigSchema = z.object({ // The maximum jitter before the next cue is flushed, in milliseconds. The player's jitter buffer // should be at least this large; absent means each cue is flushed immediately. - jitter: z.optional(u53Schema), + jitter: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** Schema for the catalog text section: a map of track name to rendition config. */ diff --git a/js/watch/src/audio/unlock.test.ts b/js/hang/src/util/gesture.test.ts similarity index 89% rename from js/watch/src/audio/unlock.test.ts rename to js/hang/src/util/gesture.test.ts index 1dba2f807e..f65087dbb3 100644 --- a/js/watch/src/audio/unlock.test.ts +++ b/js/hang/src/util/gesture.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Effect } from "@moq/signals"; -import { unlockOnGesture } from "./unlock"; +import { unlock } from "./gesture"; // Minimal AudioContext stand-in: an EventTarget with a mutable `state` and a counting // `resume()`. `transition` mirrors a real context firing `statechange` when its state moves. @@ -38,7 +38,7 @@ const asContext = (ctx: MockContext) => ctx as unknown as AudioContext; test("retries resume() on a user gesture until the context is running", async () => { const ctx = new MockContext(); const effect = new Effect(); - unlockOnGesture(effect, asContext(ctx)); + unlock(effect, asContext(ctx)); await flush(); // The at-load attempt fires once. Browsers requiring a gesture reject it, but we still @@ -53,11 +53,15 @@ test("retries resume() on a user gesture until the context is running", async () document.dispatchEvent(new Event("keydown")); expect(ctx.resumeCalls).toBe(3); + // Touch and pen only grant activation on pointerup, so a tap must retry there too. + document.dispatchEvent(new Event("pointerup")); + expect(ctx.resumeCalls).toBe(4); + // Once the context is actually running, stop retrying: further gestures are no-ops. ctx.transition("running"); await flush(); document.dispatchEvent(new Event("pointerdown")); - expect(ctx.resumeCalls).toBe(3); + expect(ctx.resumeCalls).toBe(4); effect.close(); }); @@ -65,7 +69,7 @@ test("retries resume() on a user gesture until the context is running", async () test("re-arms when Safari drops the context to interrupted", async () => { const ctx = new MockContext(); const effect = new Effect(); - unlockOnGesture(effect, asContext(ctx)); + unlock(effect, asContext(ctx)); await flush(); ctx.transition("running"); @@ -87,7 +91,7 @@ test("re-arms when Safari drops the context to interrupted", async () => { test("stops resuming after the effect closes", async () => { const ctx = new MockContext(); const effect = new Effect(); - unlockOnGesture(effect, asContext(ctx)); + unlock(effect, asContext(ctx)); await flush(); effect.close(); diff --git a/js/hang/src/util/gesture.ts b/js/hang/src/util/gesture.ts new file mode 100644 index 0000000000..e92b72998d --- /dev/null +++ b/js/hang/src/util/gesture.ts @@ -0,0 +1,38 @@ +import { type Effect, type Getter, Signal } from "@moq/signals"; + +/** + * Resume a suspended {@link AudioContext} from a real user gesture, returning whether it is running. + * + * A context built before any user activation starts suspended in browsers that gate audio on a + * gesture, and a `resume()` made then is rejected. A single unconditional attempt would fire once, + * be rejected, and never retry, leaving the graph silent. This instead attempts `resume()` + * immediately (for autoplay-permissive browsers like Chrome with prior engagement), then retries on + * every gesture until the context is actually running, dropping the listeners once it is. A mouse + * grants activation on `pointerdown` but touch and pen only on `pointerup`, so both are listened to, + * plus `keydown`. + * + * Safari also reports an "interrupted" state (a WebKit-only value outside the + * suspended/running/closed set) and can leave it on its own; mirroring `statechange` into the + * returned signal picks that up so the listeners are re-armed or dropped as the state moves. + * + * Scoped to `effect`: the listeners are removed when the effect reruns or closes. + */ +export function unlock(effect: Effect, context: AudioContext): Getter { + const running = new Signal(context.state === "running"); + effect.event(context, "statechange", () => running.set(context.state === "running")); + + effect.run((inner) => { + if (inner.get(running)) return; + + const resume = () => { + context.resume().catch(() => {}); + }; + + resume(); + inner.event(document, "pointerdown", resume); + inner.event(document, "pointerup", resume); + inner.event(document, "keydown", resume); + }); + + return running; +} diff --git a/js/hang/src/util/index.ts b/js/hang/src/util/index.ts index cef64e9f45..a09394873b 100644 --- a/js/hang/src/util/index.ts +++ b/js/hang/src/util/index.ts @@ -1,11 +1,12 @@ /** * Miscellaneous helpers for the hang media layer: AAC and Opus codec constraints, hex encoding, - * and the libav/WebCodecs polyfill. + * the libav/WebCodecs polyfill, and unlocking Web Audio on a user gesture. * * @module */ export * as Aac from "./aac"; +export * as Gesture from "./gesture"; export * as Hacks from "./hacks"; export * as Hex from "./hex"; export * as Libav from "./libav"; diff --git a/js/json/src/stream/consumer.ts b/js/json/src/stream/consumer.ts index 0d93d8dd10..6ca14024be 100644 --- a/js/json/src/stream/consumer.ts +++ b/js/json/src/stream/consumer.ts @@ -1,4 +1,5 @@ import type * as Moq from "@moq/net"; +import { race } from "@moq/signals"; import { Decoder } from "./decoder.ts"; import type { Config as CodecConfig } from "./encoder.ts"; @@ -101,7 +102,7 @@ export class Consumer { if (buffered) return buffered; const frame = group.readFrame(); - const winner = await Promise.race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); + const winner = await race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); if ("frame" in winner) return winner.frame; if (winner.group) { diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 5e93b54724..1e342998c2 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -110,3 +110,42 @@ test("a second concurrent read is refused rather than served the first one's gro expect(() => consumer.next()).toThrow("multiple calls to next not supported"); expect(await first).toEqual({ n: 0 }); }); + +// Counts the reactions `run` attaches to promises still pending once it returns. A promise holds each +// reaction until it settles, so one left per iteration on a promise that outlives the loop is a leak. +// Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + +// A blocked read races the frame against the track's next group, which stays pending for the whole +// log. Racing it per record must not leave a reaction behind on it each time. +test("blocked reads leave nothing behind on the pending group read", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const subscriber = track.subscribe(); + const consumer = new Consumer({ track: subscriber }); + + const reactions = await pendingReactions(async () => { + for (let n = 0; n < 1000; n++) { + const next = consumer.next(); + producer.append({ n }); + expect((await next)?.n).toBe(n); + } + }); + expect(reactions).toBeLessThan(10); + + subscriber.close(); + producer.finish(); +}); diff --git a/js/moq-boy/src/element.tsx b/js/moq-boy/src/element.tsx index b776fd8e52..48346b6de1 100644 --- a/js/moq-boy/src/element.tsx +++ b/js/moq-boy/src/element.tsx @@ -135,7 +135,7 @@ export default class MoqBoy extends HTMLElement { effect.spawn(async () => { for (;;) { - const entry = await Promise.race([effect.cancel, announced.next()]); + const entry = await effect.race(announced.next()); if (!entry) break; // A broad route that cannot pin the game id names nothing to open. diff --git a/js/moq-boy/src/game.ts b/js/moq-boy/src/game.ts index c9bec3f4d5..9d2a03d39d 100644 --- a/js/moq-boy/src/game.ts +++ b/js/moq-boy/src/game.ts @@ -266,7 +266,7 @@ export class Game { const consumer = new Json.Snapshot.Consumer({ track: statusTrack, schema: GameStatusSchema }); // Closing the track on cleanup unblocks a pending next() (it returns undefined), so the loop - // ends without racing effect.cancel. + // ends without racing the teardown. effect.spawn(async () => { for (;;) { let status: GameStatus | undefined; diff --git a/js/net/examples/wait.ts b/js/net/examples/wait.ts index 5bf4d8170a..79adf1babf 100644 --- a/js/net/examples/wait.ts +++ b/js/net/examples/wait.ts @@ -28,7 +28,7 @@ async function main() { effect.spawn(async () => { for (;;) { - const group = await Promise.race([effect.cancel, track.recvGroup()]); + const group = await effect.race(track.recvGroup()); if (!group) break; console.log("received:", await group.readString()); } diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 8c0f25527f..01056ae153 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -38,6 +38,21 @@ export interface Update { route: Route; } +/** + * Options for an announcement stream. + * + * @public + */ +export interface Options { + /** + * Also report hidden routes: those with a path segment starting with `.` below the + * scope's literal head. Hidden routes are left out by default, so a platform can add + * `.`-named broadcasts (stats, internal routes) without them turning up in apps that + * list everything. Subscribing to a hidden path by name works either way. + */ + hidden?: boolean; +} + /** Whether a route covers the path after an update of this {@link Kind}. */ export function isActive(kind: Kind): boolean { return kind !== "retracted"; diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index c048ec5768..d0f4f2c297 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -87,6 +87,8 @@ async function acceptInner( return acceptSetup(transport, url, Ietf.Version.DRAFT_16, wiring); } else if (protocol === Ietf.ALPN.DRAFT_15) { return acceptSetup(transport, url, Ietf.Version.DRAFT_15, wiring); + } else if (protocol === Lite.ALPN_07_WIP) { + return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_06, ...wiring }); } else if (protocol === Lite.ALPN_05) { @@ -112,7 +114,7 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, cluster } = await exchangeSetup(transport, version, "moq-lite-js"); + const { control, solicit, hidden, cluster } = await exchangeSetup(transport, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -121,6 +123,7 @@ async function acceptAlpn( quic: transport, control, solicit, + hidden, cluster, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, @@ -156,6 +159,7 @@ async function acceptSetup( params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version, parameters: params }); await server.encode(stream.writer, version); @@ -171,6 +175,7 @@ async function acceptSetup( maxRequestId, version, solicit: Ietf.solicitFromSetup(client.parameters), + hidden: Ietf.hiddenFromSetup(client.parameters), }); } @@ -214,6 +219,7 @@ async function acceptNegotiated( params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params }); await server.encode(stream.writer, setupVersion); @@ -237,6 +243,7 @@ async function acceptNegotiated( maxRequestId, version: selectedVersion as Ietf.IetfVersion, solicit: Ietf.solicitFromSetup(client.parameters), + hidden: Ietf.hiddenFromSetup(client.parameters), }); } else { throw new Error(`unsupported version: ${selectedVersion.toString(16)}`); diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 22dc73115b..c3cedd6607 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { ALPN_05, ALPN_06 } from "../lite/version.ts"; +import { ALPN_05, ALPN_06, ALPN_07_WIP } from "../lite/version.ts"; import { createMockTransportPair } from "../mock.ts"; import { type ConnectProps, connect as connectSession } from "./connect.ts"; @@ -54,8 +54,10 @@ function stubWebTransport(transport: WebTransport): () => void { }; } -test("WebTransport offers lite-06 first by default", async () => { - const pair = createMockTransportPair(ALPN_06); +// Connect through a stubbed `new WebTransport(...)`, returning the offered protocols and +// the negotiated version. +async function offered(alpn: string, props: Omit = {}) { + const pair = createMockTransportPair(alpn); const original = globalThis.WebTransport; let protocols: string[] | undefined; @@ -66,13 +68,24 @@ test("WebTransport offers lite-06 first by default", async () => { globalThis.WebTransport = StubWebTransport as unknown as typeof WebTransport; try { - const connection = await connect(url, { websocket: { enabled: false } }); + const connection = await connect(url, { websocket: { enabled: false }, ...props }); connection.close(); + return { protocols, version: connection.version }; } finally { globalThis.WebTransport = original; } +} +test("WebTransport offers lite-06 first by default", async () => { + const { protocols } = await offered(ALPN_06); expect(protocols?.[0]).toBe("moq-lite-06"); + expect(protocols).not.toContain(ALPN_07_WIP); +}); + +test("WebTransport negotiates lite-07-wip only when explicitly offered", async () => { + const { protocols, version } = await offered(ALPN_07_WIP, { webtransport: { protocols: [ALPN_07_WIP] } }); + expect(protocols).toEqual(["moq-lite-07-wip"]); + expect(version).toBe("moq-lite-07-wip"); }); test("connect logs the relay URL without its credentials", async () => { diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 185cd4fecc..759c3cceb0 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -281,6 +281,8 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): setupVersion = Ietf.Version.DRAFT_16; } else if (protocol === Ietf.ALPN.DRAFT_15) { setupVersion = Ietf.Version.DRAFT_15; + } else if (protocol === Lite.ALPN_07_WIP) { + return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_06, ...wiring }); } else if (protocol === Lite.ALPN_05) { @@ -304,6 +306,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); const client = new Ietf.ClientSetup({ versions: @@ -342,6 +345,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): maxRequestId, version: server.version as Ietf.IetfVersion, solicit: Ietf.solicitFromSetup(server.parameters), + hidden: Ietf.hiddenFromSetup(server.parameters), }); } else { throw new Error(`unsupported server version: ${server.version.toString()}`); @@ -358,7 +362,7 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, cluster } = await exchangeSetup(session, version, "moq-lite-js"); + const { control, solicit, hidden, cluster } = await exchangeSetup(session, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -367,6 +371,7 @@ async function handshakeAlpn( quic: session, control, solicit, + hidden, cluster, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, @@ -433,6 +438,8 @@ async function connectWebTransport( allowPooling: false, congestionControl: "low-latency", protocols: [ + // Lite.ALPN_07_WIP is intentionally omitted: lite-07 is work-in-progress and + // not advertised by default (negotiate still accepts it if a server selects it). Lite.ALPN_06, Lite.ALPN_05, Lite.ALPN_04, @@ -519,6 +526,7 @@ async function connectWebSocket(url: URL, delay: number, cancel: Promise): // advertises every QMux draft it knows about and the server picks one. // Insertion order is the negotiation preference on the wire. const versions = { + // Lite.ALPN_07_WIP omitted on purpose: lite-07 is work-in-progress, not advertised by default. [Lite.ALPN_06]: null, [Lite.ALPN_05]: null, [Lite.ALPN_04]: null, diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index c8d7278217..f796892762 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -37,8 +37,9 @@ export interface Established { * Subscribe to broadcast announcements matching `scope`, any pattern (`foo/**` * for a subtree, `room/* /chat` for each room's chat, default `**`). Paths are * relative to the session; captures report what the scope's wildcards stood for. + * Hidden routes are left out unless `options.hidden` opts in. */ - announced(scope?: Path.Pattern): announce.Consumer; + announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer; /** * Snapshot the transport's counters, querying it fresh on each call. diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index ff977a61c1..66745398a9 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -3,7 +3,7 @@ * * @module */ -import type { Dispose } from "@moq/signals"; +import { type Dispose, race } from "@moq/signals"; import { isActive } from "../announced.ts"; import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; import type * as Path from "../path.ts"; @@ -47,7 +47,8 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi return; } - const announced = conn.announced(); + // Hidden routes are mirrored too; each local reader opts in on its own. + const announced = conn.announced(undefined, { hidden: true }); const inserted = new Map(); // End the stream the moment the session closes rather than waiting for the wire to @@ -159,7 +160,7 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise // Woken by the table too, not just the requests: a path that stops being routed needs // the blind answer this loop skipped while it was. - await Promise.race([table.changed(), closed]); + await race([table.changed(), closed]); } // Session gone: withdraw our answers, waking a standby session to provide fresh ones. diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index ae7154674e..05cd214c8b 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -19,11 +19,12 @@ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, -): Promise<{ control: Stream; solicit: boolean | undefined; cluster: Ietf.Cluster.Hops }> { +): Promise<{ control: Stream; solicit: boolean | undefined; hidden: boolean; cluster: Ietf.Cluster.Hops }> { const encoder = new TextEncoder(); const params = new Ietf.SetupOptions(); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation)); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. @@ -40,6 +41,7 @@ export async function exchangeSetup( return { control: new Stream({ writer, reader: received.reader }), solicit: received.solicit, + hidden: received.hidden, cluster: { self, peer: received.cluster }, }; } @@ -56,7 +58,7 @@ async function sendSetup(transport: WebTransport, version: Ietf.IetfVersion, set async function receiveSetup( transport: WebTransport, version: Ietf.IetfVersion, -): Promise<{ reader: Reader; solicit: boolean | undefined; cluster: Hop | undefined }> { +): Promise<{ reader: Reader; solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined }> { const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< ReadableStream >; @@ -75,6 +77,7 @@ async function receiveSetup( return { reader, solicit: Ietf.solicitFromSetup(setup.parameters), + hidden: Ietf.hiddenFromSetup(setup.parameters), cluster: Ietf.Cluster.fromSetup(setup.parameters, version), }; } diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 20e4d5d18e..959697ca09 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -265,7 +265,7 @@ export class Connection { * and URL switches: a switch retracts everything from * the old relay's origin, then the new one's arrivals stream in. */ - announced(scope: Path.Pattern = Path.Pattern.all()): Announce.Consumer { + announced(scope: Path.Pattern = Path.Pattern.all(), options?: Announce.Options): Announce.Consumer { const producer = new Announce.Producer(); const consumer = producer.consume(); @@ -283,7 +283,7 @@ export class Connection { const origin = effect.get(this.#origin); if (!origin) return; - const upstream = origin.announced(scope); + const upstream = origin.announced(scope, options); effect.cleanup(() => upstream.close()); // Track what this origin announced so a URL switch retracts it; the last @@ -293,7 +293,7 @@ export class Connection { effect.spawn(async () => { try { for (;;) { - const entry = await Promise.race([effect.cancel, upstream.next()]); + const entry = await effect.race(upstream.next()); if (!entry) break; if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); else active.delete(entry.prefix); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 5063fbff8d..b7b77bcaef 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -255,7 +255,7 @@ export class Reload { if (pending) return; pending = true; try { - const stats = await Promise.race([effect.cancel, connection.stats()]); + const stats = await effect.race(connection.stats()); if (stats) this.#estimate.set(stats.estimatedSendRate); } finally { pending = false; @@ -341,7 +341,7 @@ export class Reload { // A cancelled effect resolves undefined, so the sentinel tells the session // closing (null for clean, an Error otherwise) apart from this run being // torn down. - const closed = await Promise.race([effect.cancel, connection.closed]); + const closed = await effect.race(connection.closed); if (closed === undefined) return; console.warn("connection closed, reconnecting"); @@ -443,10 +443,10 @@ export class Reload { * * Stays empty while the relay lacks {@link Established.discovery}. */ - announced(scope: Path.Pattern = Path.Pattern.all()): Announce.Consumer { + announced(scope: Path.Pattern = Path.Pattern.all(), options?: Announce.Options): Announce.Consumer { // With a consume origin the table already spans reconnects (the forwarder retracts // a dead session's entries), so its stream is the same thing with less machinery. - if (this.consume) return this.consume.announced(scope); + if (this.consume) return this.consume.announced(scope, options); const producer = new Announce.Producer(); const consumer = producer.consume(); @@ -460,7 +460,7 @@ export class Reload { // consumer empty rather than opening a subscription that can't be answered. if (!conn.discovery) return; - const upstream = conn.announced(scope); + const upstream = conn.announced(scope, options); effect.cleanup(() => upstream.close()); // Track what this connection announced so we can retract it if the connection @@ -470,7 +470,7 @@ export class Reload { effect.spawn(async () => { try { for (;;) { - const entry = await Promise.race([effect.cancel, upstream.next()]); + const entry = await effect.race(upstream.next()); if (!entry) break; if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); else active.delete(entry.prefix); diff --git a/js/net/src/ietf/adapter.test.ts b/js/net/src/ietf/adapter.test.ts index 5f7ea4c06b..1945cef825 100644 --- a/js/net/src/ietf/adapter.test.ts +++ b/js/net/src/ietf/adapter.test.ts @@ -8,6 +8,18 @@ import { PublishNamespace, PublishNamespaceCancel, PublishNamespaceDone } from " import { RequestError } from "./request.ts"; import { ALPN, Version } from "./version.ts"; +test("draft-14 TRACK_STATUS_OK cannot be routed as NAMESPACE_DONE", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_14); + const control = await Stream.open(pair.server, { version: Version.DRAFT_14 }); + const adapter = new ControlStreamAdapter(pair.server, control, Version.DRAFT_14, 100n, true); + const running = adapter.run(); + const peer = await Stream.accept(pair.client, Version.DRAFT_14); + if (!peer) throw new Error("no control stream"); + await peer.writer.u53(0x0e); + await peer.writer.u16(0); + await expect(running).rejects.toThrow("unexpected message 0x0e"); +}); + // Draft-15 is the interesting one: it names its namespace withdrawals instead of // numbering them, so the adapter has to resolve them through a map it keeps itself. const VERSION = Version.DRAFT_15; diff --git a/js/net/src/ietf/adapter.ts b/js/net/src/ietf/adapter.ts index f0a766370a..fd16a5349d 100644 --- a/js/net/src/ietf/adapter.ts +++ b/js/net/src/ietf/adapter.ts @@ -58,7 +58,7 @@ export class NativeSession implements Session { const Route = { NewRequest: 0, // Create virtual bidi stream, push initial message Response: 1, // Push message to existing stream (keep open) - ErrorResponse: 2, // Push message to existing stream, then close + ErrorResponse: 2, // Push a final message to existing stream, then close CloseStream: 3, // Close stream recv (no bytes pushed) FollowUp: 4, // Push follow-up message to existing stream MaxRequestId: 5, // Update flow control @@ -636,7 +636,10 @@ export class ControlStreamAdapter implements Session { return { route: Route.FollowUp, requestId: subNs08 }; } case 0x0e: { - // v15: NamespaceDone entry (no requestId) — route to SubscribeNamespace stream + if (this.version === Version.DRAFT_14 || this.version === Version.DRAFT_15) { + throw new Error("unexpected message 0x0e"); + } + // v16+: NamespaceDone entry (no requestId) — route to SubscribeNamespace stream const subNs0e = this.#subscribeNamespaces.values().next().value; if (subNs0e === undefined) throw new Error("unexpected message 0x0e: no SubscribeNamespace stream"); return { route: Route.FollowUp, requestId: subNs0e }; @@ -655,9 +658,9 @@ export class ControlStreamAdapter implements Session { return { route: Route.CloseStream, requestId }; } case 0x0b: { - // PublishDone + // PublishDone: the subscriber reads its status and stream count before the end. const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; + return { route: Route.ErrorResponse, requestId }; } case 0x17: { // FetchCancel diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index d4d24eb416..e1d22f9bf8 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -85,6 +85,7 @@ export class Connection implements Established { discovery = true, publish, solicit, + hidden = false, cluster, }: { url: URL; @@ -102,6 +103,8 @@ export class Connection implements Established { * nothing, which is the one case where announcing at us unasked is not a bug. */ solicit?: boolean; + /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ + hidden?: boolean; /** * The Hop IDs this session declared (MoQ Cluster). `undefined` on a version that * cannot negotiate the extension, as is a `peer` the peer never declared. @@ -138,7 +141,7 @@ export class Connection implements Established { }); this.#solicit = solicit; this.#cluster = cluster; - this.#subscriber = new Subscriber({ session: this.#session, cluster }); + this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); @@ -179,8 +182,8 @@ export class Connection implements Established { } /** Gets an announced reader for `scope`; see {@link Established.announced}. */ - announced(scope?: Path.Pattern): announce.Consumer { - return this.#subscriber.announced(scope); + announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer { + return this.#subscriber.announced(scope, options); } /** @@ -219,7 +222,11 @@ export class Connection implements Established { } case SubscribeNamespaceLegacy.id: { const legacy = await SubscribeNamespaceLegacy.decode(stream.reader, this.#session.version); - const msg = new SubscribeNamespace({ requestId: legacy.requestId, namespace: legacy.namespace }); + const msg = new SubscribeNamespace({ + requestId: legacy.requestId, + namespace: legacy.namespace, + hidden: legacy.hidden, + }); await this.#publisher.runSubscribeNamespace(msg, stream); break; } diff --git a/js/net/src/ietf/error.test.ts b/js/net/src/ietf/error.test.ts index 0c84f1d0dc..dbee25afa3 100644 --- a/js/net/src/ietf/error.test.ts +++ b/js/net/src/ietf/error.test.ts @@ -11,9 +11,18 @@ const ALL: IetfVersion[] = [ Version.DRAFT_18, Version.DRAFT_19, Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, ]; -const KINDS: RequestKind[] = ["subscribe", "fetch", "publish", "publish_namespace", "subscribe_namespace"]; +const KINDS: RequestKind[] = [ + "subscribe", + "fetch", + "publish", + "publish_namespace", + "subscribe_namespace", + "track_status", +]; /** Every condition a rejection can carry. A new one belongs here. */ const CONDITIONS: RequestCondition[] = [ @@ -22,6 +31,8 @@ const CONDITIONS: RequestCondition[] = [ "timeout", "not_supported", "does_not_exist", + "invalid_range", + "invalid_joining_request_id", "uninterested", "malformed_track", "going_away", @@ -45,6 +56,7 @@ function registered(kind: RequestKind, version: IetfVersion): number[] { case "publish_namespace": return [0x0, 0x1, 0x2, 0x3, 0x4, 0x10, 0x12]; case "subscribe_namespace": + case "track_status": return [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x10, 0x12]; } } @@ -58,6 +70,19 @@ function registered(kind: RequestKind, version: IetfVersion): number[] { ]; } +test("fetch refusal codes follow each draft registry", () => { + for (const version of ALL) { + const range = version === Version.DRAFT_14 ? 0x5 : 0x11; + expect(toRequestCode("invalid_range", "fetch", version)).toBe(range); + expect(fromRequestCode(range, "fetch", version)).toBe("invalid_range"); + const joining = version === Version.DRAFT_14 ? 0x7 : version <= Version.DRAFT_19 ? 0x32 : undefined; + expect(toRequestCode("invalid_joining_request_id", "fetch", version)).toBe(joining ?? 0); + if (joining !== undefined) + expect(fromRequestCode(joining, "fetch", version)).toBe("invalid_joining_request_id"); + else expect(fromRequestCode(0x32, "fetch", version)).toBeUndefined(); + } +}); + test("only registered codes reach the wire", () => { for (const version of ALL) { for (const kind of KINDS) { diff --git a/js/net/src/ietf/error.ts b/js/net/src/ietf/error.ts index 886594f703..21a5a0b526 100644 --- a/js/net/src/ietf/error.ts +++ b/js/net/src/ietf/error.ts @@ -66,7 +66,13 @@ export function sharedStreamCode(code: number, version: IetfVersion): boolean { * * @internal */ -export type RequestKind = "subscribe" | "fetch" | "publish" | "publish_namespace" | "subscribe_namespace"; +export type RequestKind = + | "subscribe" + | "fetch" + | "publish" + | "publish_namespace" + | "subscribe_namespace" + | "track_status"; /** * What a rejection reports, before the draft picks the number for it. @@ -86,6 +92,8 @@ export type RequestCondition = /** This endpoint does not implement the request at all. */ | "not_supported" /** The broadcast or track the peer asked for is not here. */ + | "invalid_range" + | "invalid_joining_request_id" | "does_not_exist" /** The content the peer offered is not wanted here, so it should stop offering it. */ | "uninterested" @@ -103,6 +111,23 @@ const NOT_SUPPORTED = 0x3; /** A draining session. Draft-17 and later. */ const GOING_AWAY = 0x6; +/** FETCH-only errors. INVALID_JOINING_REQUEST_ID was removed in draft-20. */ +const INVALID_RANGE_14 = 0x5; +const INVALID_RANGE = 0x11; +const INVALID_JOINING_REQUEST_ID_14 = 0x7; +const INVALID_JOINING_REQUEST_ID = 0x32; + +function invalidRange(kind: RequestKind, version: IetfVersion): number | undefined { + if (kind !== "fetch") return undefined; + return version === Version.DRAFT_14 ? INVALID_RANGE_14 : INVALID_RANGE; +} + +function invalidJoiningRequestId(kind: RequestKind, version: IetfVersion): number | undefined { + if (kind !== "fetch") return undefined; + if (version === Version.DRAFT_14) return INVALID_JOINING_REQUEST_ID_14; + return version <= Version.DRAFT_19 ? INVALID_JOINING_REQUEST_ID : undefined; +} + /** Draft-14 calls it TRACK_DOES_NOT_EXIST; draft-15 renamed it and moved it off 0x4. */ const DOES_NOT_EXIST_14 = 0x4; const DOES_NOT_EXIST = 0x10; @@ -118,7 +143,7 @@ const MALFORMED_TRACK = 0x12; /** The value for "the thing you asked for is not here", or undefined where none is assigned. */ function doesNotExist(kind: RequestKind, version: IetfVersion): number | undefined { if (version !== Version.DRAFT_14) return DOES_NOT_EXIST; - return kind === "subscribe" || kind === "fetch" ? DOES_NOT_EXIST_14 : undefined; + return kind === "subscribe" || kind === "fetch" || kind === "track_status" ? DOES_NOT_EXIST_14 : undefined; } /** The value for "we do not want this", or undefined where none is assigned. */ @@ -159,13 +184,17 @@ export function toRequestCode(condition: RequestCondition, kind: RequestKind, ve return TIMEOUT; case "not_supported": return NOT_SUPPORTED; + case "invalid_range": + return invalidRange(kind, version) ?? INTERNAL_ERROR; + case "invalid_joining_request_id": + return invalidJoiningRequestId(kind, version) ?? INTERNAL_ERROR; case "does_not_exist": return doesNotExist(kind, version) ?? INTERNAL_ERROR; // A subscriber that asked for content cannot act on "we do not want it": what it needs // to know is that we do not have it, which is the same refusal from its side. Only the // requests that offer content say UNINTERESTED. case "uninterested": - return kind === "subscribe" || kind === "fetch" + return kind === "subscribe" || kind === "fetch" || kind === "track_status" ? (doesNotExist(kind, version) ?? INTERNAL_ERROR) : (uninterested(kind, version) ?? INTERNAL_ERROR); case "malformed_track": @@ -194,6 +223,8 @@ export function fromRequestCode(code: number, kind: RequestKind, version: IetfVe } if (code === doesNotExist(kind, version)) return "does_not_exist"; + if (code === invalidRange(kind, version)) return "invalid_range"; + if (code === invalidJoiningRequestId(kind, version)) return "invalid_joining_request_id"; if (code === uninterested(kind, version)) return "uninterested"; if (code === malformedTrack(kind, version)) return "malformed_track"; if (code === goingAway(version)) return "going_away"; diff --git a/js/net/src/ietf/hidden.ts b/js/net/src/ietf/hidden.ts new file mode 100644 index 0000000000..cd22aff02f --- /dev/null +++ b/js/net/src/ietf/hidden.ts @@ -0,0 +1,31 @@ +import { SetupOption, type SetupOptions } from "./parameters.ts"; + +/** + * The MoQ Hidden extension (draft-lcurley-moq-hidden-00). + * + * A namespace with a field starting with `.` below the prefix a subscription asked for is + * left out of discovery unless the SUBSCRIBE_NAMESPACE opts in with the HIDDEN parameter. + * An unknown parameter fails decoding, so the parameter is only sent to a peer whose SETUP + * carried the HIDDEN option. + * + * @module + * @internal + */ + +/** + * Whether the peer understands the HIDDEN parameter. + * + * @internal + */ +export function hiddenFromSetup(params: SetupOptions): boolean { + return params.getVarint(SetupOption.Hidden) !== undefined; +} + +/** + * Declare that we understand the HIDDEN parameter. + * + * @internal + */ +export function hiddenIntoSetup(params: SetupOptions) { + params.setVarint(SetupOption.Hidden, 1n); +} diff --git a/js/net/src/ietf/ietf.test.ts b/js/net/src/ietf/ietf.test.ts index 949bd5a3cd..ba648c6162 100644 --- a/js/net/src/ietf/ietf.test.ts +++ b/js/net/src/ietf/ietf.test.ts @@ -90,6 +90,77 @@ async function encodeFetchFrameVersioned( return concatChunks(written); } +test("DEFAULT_PUBLISHER_PRIORITY has exact SUBSCRIBE_OK bytes per draft", async () => { + for (const version of [ + Version.DRAFT_14, + Version.DRAFT_15, + Version.DRAFT_16, + Version.DRAFT_17, + Version.DRAFT_18, + Version.DRAFT_19, + Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, + ] as const) { + const requestId = version <= Version.DRAFT_16 ? 7n : undefined; + const baseline = await encodeVersioned(new Subscribe.SubscribeOk({ requestId, trackAlias: 42n }), version); + const encoded = await encodeVersioned( + new Subscribe.SubscribeOk({ requestId, trackAlias: 42n, properties: { priority: 37 } }), + version, + ); + if (version <= Version.DRAFT_16) { + expect(Array.from(encoded)).toEqual(Array.from(baseline)); + } else { + expect(Array.from(encoded)).toEqual([baseline[0], baseline[1] + 2, ...baseline.slice(2), 0x0e, 37]); + const decoded = await decodeVersioned(encoded, Subscribe.SubscribeOk.decode, version); + expect(decoded.properties.priority).toBe(37); + } + } +}); + +test("DEFAULT_PUBLISHER_PRIORITY has exact PUBLISH bytes per draft", async () => { + for (const version of [ + Version.DRAFT_14, + Version.DRAFT_15, + Version.DRAFT_16, + Version.DRAFT_17, + Version.DRAFT_18, + Version.DRAFT_19, + Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, + ] as const) { + const fields = { + requestId: 1n, + trackNamespace: Path.from("ns"), + trackName: "video", + trackAlias: 42n, + groupOrder: 2, + contentExists: false, + largest: undefined, + forward: true, + }; + const baseline = await encodeVersioned(new Publish(fields), version); + const encoded = await encodeVersioned(new Publish({ ...fields, priority: 37 }), version); + if (version <= Version.DRAFT_16) { + expect(Array.from(encoded)).toEqual(Array.from(baseline)); + } else { + // The new property precedes GROUP_ORDER, so its delta changes 0x22 to 0x14. + expect(Array.from(encoded)).toEqual([ + baseline[0], + baseline[1] + 2, + ...baseline.slice(2, -2), + 0x0e, + 37, + 0x14, + 2, + ]); + const decoded = await decodeVersioned(encoded, Publish.decode, version); + expect(decoded.priority).toBe(37); + } + } +}); + test("Message Parameters: uint8 wire encoding changes in draft 17", async () => { const params = new Parameters(); params.subscriberPriority = 255; diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index 9fd3ec8474..e1ebb8199d 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -4,6 +4,7 @@ export * from "./connection.ts"; export * from "./control.ts"; export * from "./fetch.ts"; export * from "./goaway.ts"; +export * from "./hidden.ts"; export * from "./object.ts"; export * from "./parameters.ts"; export * from "./publish.ts"; diff --git a/js/net/src/ietf/object.ts b/js/net/src/ietf/object.ts index 6599d52216..974b44706b 100644 --- a/js/net/src/ietf/object.ts +++ b/js/net/src/ietf/object.ts @@ -3,6 +3,7 @@ import { Timescale, Timestamp } from "../time.ts"; import { type IetfVersion, Version } from "./version.ts"; const GROUP_END = 0x03; +const END_OF_TRACK = 0x04; // MOQ Object Property ids, shared with draft-ietf-moq-loc-04. const PROP_TIMESCALE = 0x08n; @@ -259,14 +260,24 @@ export class Group { /** A moq-transport object inside a group stream. */ export class Frame { - /** The object payload, or `undefined` for the end of group marker. */ + /** The object payload, or `undefined` for an end of group or end of track marker. */ payload?: Uint8Array; /** The presentation timestamp carried in object properties, when present. */ timestamp?: Timestamp; + /** + * An END_OF_TRACK marker: no object at or past its location exists. At object 0 its group + * does not exist either, so the track ends at that group; later in a group it ends after it. + */ + endOfTrack: boolean; - constructor({ payload, timestamp }: { payload?: Uint8Array; timestamp?: Timestamp } = {}) { + constructor({ + payload, + timestamp, + endOfTrack = false, + }: { payload?: Uint8Array; timestamp?: Timestamp; endOfTrack?: boolean } = {}) { this.payload = payload; this.timestamp = timestamp; + this.endOfTrack = endOfTrack; } /** @@ -284,7 +295,10 @@ export class Frame { await w.write(extensions); } - if (this.payload !== undefined) { + if (this.endOfTrack) { + await w.u53(0); // length = 0 + await w.u53(END_OF_TRACK); + } else if (this.payload !== undefined) { await w.u53(this.payload.byteLength); if (this.payload.byteLength === 0) { @@ -334,6 +348,9 @@ export class Frame { const status = await r.u53(); + // Defined on every implemented draft, whether or not the header marks the group's end. + if (status === END_OF_TRACK) return new Frame({ endOfTrack: true }); + if (flags.hasEnd) { // Empty frame if (status === 0) return new Frame({ payload: new Uint8Array(0), timestamp }); diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index 5d9762b6da..f2a6a3120b 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -16,6 +16,8 @@ export const SetupOption = { RelayCost: 0x40b56n, /** SOLICIT, from the MoQ Solicit extension. See `solicit.ts`. */ Solicit: 0x40b5an, + /** HIDDEN, from the MoQ Hidden extension. See `hidden.ts`. */ + Hidden: 0x40b5cn, } as const; /// Setup Options — used in SETUP messages. @@ -202,6 +204,8 @@ const MSG_PARAM_SUBSCRIBER_PRIORITY = 0x20n; const MSG_PARAM_GROUP_ORDER = 0x22n; /// ROUTE_COST, from the MoQ Cluster extension. See `cluster.ts`. const MSG_PARAM_ROUTE_COST = 0x40b58n; +/// HIDDEN, from the MoQ Hidden extension. See `hidden.ts`. +const MSG_PARAM_HIDDEN = 0x40b5en; // Bytes parameter IDs (odd) const MSG_PARAM_LARGEST_OBJECT = 0x09n; @@ -225,6 +229,7 @@ function getMessageParamKind(id: bigint): MessageParamKind { case MSG_PARAM_MAX_CACHE_DURATION: case MSG_PARAM_EXPIRES: case MSG_PARAM_ROUTE_COST: + case MSG_PARAM_HIDDEN: return "varint"; case MSG_PARAM_PUBLISHER_PRIORITY: case MSG_PARAM_SUBSCRIBER_PRIORITY: @@ -341,6 +346,19 @@ export class Parameters { this.vars.set(MSG_PARAM_MAX_CACHE_DURATION, v); } + /** HIDDEN (MoQ Hidden): also advertise hidden namespaces. Absent and 0 both mean no. */ + get hidden(): boolean { + const v = this.vars.get(MSG_PARAM_HIDDEN); + if (v === undefined || v === 0n) return false; + if (v === 1n) return true; + throw new Error(`invalid HIDDEN parameter: ${v}`); + } + + set hidden(v: boolean) { + if (v) this.vars.set(MSG_PARAM_HIDDEN, 1n); + else this.vars.delete(MSG_PARAM_HIDDEN); + } + // --- Bytes accessors --- get largest(): MessageLocation | undefined { diff --git a/js/net/src/ietf/properties.ts b/js/net/src/ietf/properties.ts index 2a4c2b06ab..1941361ced 100644 --- a/js/net/src/ietf/properties.ts +++ b/js/net/src/ietf/properties.ts @@ -11,6 +11,7 @@ const TIMESCALE = 0x08n; // It shares its number with the GROUP_ORDER *message parameter*, which is a different // registry: that one is only legal in SUBSCRIBE, PUBLISH_OK, and FETCH, where the // subscriber states its own preference. +const DEFAULT_PUBLISHER_PRIORITY = 0x0en; const DEFAULT_PUBLISHER_GROUP_ORDER = 0x22n; /** @@ -26,6 +27,9 @@ export interface Properties { /// `undefined` declares no timeline, so the subscriber times objects by arrival. timescale?: Timescale; + /// Publisher priority for a group header without its priority flag. The wire default is 128. + priority?: number; + /// The publisher's preference for prioritizing groups within a subscription, /// Ascending (0x1) or Descending (0x2). /// @@ -54,6 +58,15 @@ export async function encode(w: Writer, properties: Properties, version: IetfVer prevType = TIMESCALE; } + if (properties.priority !== undefined) { + if (!Number.isInteger(properties.priority) || properties.priority < 0 || properties.priority > 255) { + throw new RangeError(`invalid publisher priority: ${properties.priority}`); + } + await w.u62(DEFAULT_PUBLISHER_PRIORITY - prevType); + await w.u62(BigInt(properties.priority)); + prevType = DEFAULT_PUBLISHER_PRIORITY; + } + if (properties.groupOrder !== undefined) { await w.u62(DEFAULT_PUBLISHER_GROUP_ORDER - prevType); await w.u62(BigInt(properties.groupOrder)); @@ -94,6 +107,9 @@ export async function decode(r: Reader, version: IetfVersion): Promise 255n) throw new Error(`invalid publisher priority: ${value}`); + properties.priority = Number(value); } else if (abs === DEFAULT_PUBLISHER_GROUP_ORDER) { // Only Ascending (0x1) and Descending (0x2) are defined here. Unlike the draft-14 // fields, 0x0 has no "publisher decides" meaning to fall back on. diff --git a/js/net/src/ietf/publish.ts b/js/net/src/ietf/publish.ts index 1b7b7d520e..40f105338a 100644 --- a/js/net/src/ietf/publish.ts +++ b/js/net/src/ietf/publish.ts @@ -16,6 +16,7 @@ export class Publish { trackName: string; trackAlias: bigint; groupOrder: number; + priority?: number; contentExists: boolean; largest: { groupId: bigint; objectId: bigint } | undefined; forward: boolean; @@ -26,6 +27,7 @@ export class Publish { trackName, trackAlias, groupOrder, + priority, contentExists, largest, forward, @@ -35,6 +37,7 @@ export class Publish { trackName: string; trackAlias: bigint; groupOrder: number; + priority?: number; contentExists: boolean; largest: { groupId: bigint; objectId: bigint } | undefined; forward: boolean; @@ -44,6 +47,7 @@ export class Publish { this.trackName = trackName; this.trackAlias = trackAlias; this.groupOrder = groupOrder; + this.priority = priority; this.contentExists = contentExists; this.largest = largest; this.forward = forward; @@ -90,7 +94,7 @@ export class Publish { await params.encode(w, version); // Track Properties are the final field, so nothing may follow. - await Properties.encode(w, { groupOrder: this.groupOrder }, version); + await Properties.encode(w, { groupOrder: this.groupOrder, priority: this.priority }, version); } } @@ -142,6 +146,7 @@ export class Publish { trackName, trackAlias, groupOrder, + priority: properties.priority, contentExists: !!largest, largest, forward, @@ -208,21 +213,53 @@ export class PublishError { } } +/** PUBLISH_DONE status codes this implementation distinguishes. Stable across drafts 14 through 22. */ +export const PublishDoneStatus = { + INTERNAL_ERROR: 0x0, + TRACK_ENDED: 0x2, + /** Removed in draft-20, where 0x3 is unassigned. */ + SUBSCRIPTION_ENDED: 0x3, +} as const; + +/** Whether a PUBLISH_DONE status ends the track cleanly rather than aborting it. */ +export function publishDoneClean(statusCode: number, version: IetfVersion): boolean { + if (statusCode === PublishDoneStatus.TRACK_ENDED) return true; + if (statusCode !== PublishDoneStatus.SUBSCRIPTION_ENDED) return false; + switch (version) { + case Version.DRAFT_14: + case Version.DRAFT_15: + case Version.DRAFT_16: + case Version.DRAFT_17: + case Version.DRAFT_18: + case Version.DRAFT_19: + return true; + default: + return false; + } +} + // In draft-14, this message is renamed from SUBSCRIBE_DONE to PUBLISH_DONE export class PublishDone { static readonly id = 0x0b; requestId: bigint | undefined; statusCode: number; + /** + * How many data streams the publisher opened for the subscription, fill streams included. + * A hint: a peer may send 0 or the "unknown" sentinel regardless. + */ + streamCount: bigint; reasonPhrase: string; constructor({ requestId, statusCode, + streamCount = 0n, reasonPhrase, - }: { requestId?: bigint; statusCode: number; reasonPhrase: string }) { + }: { requestId?: bigint; statusCode: number; streamCount?: bigint; reasonPhrase: string }) { this.requestId = requestId; this.statusCode = statusCode; + this.streamCount = streamCount; this.reasonPhrase = reasonPhrase; } @@ -232,7 +269,7 @@ export class PublishDone { await w.u62(this.requestId); } await w.u62(BigInt(this.statusCode)); - await w.u62(BigInt(0)); // stream_count = 0 (unsupported) + await w.u62(this.streamCount); await w.string(this.reasonPhrase); } @@ -250,9 +287,9 @@ export class PublishDone { ? await r.u62() : undefined; const statusCode = Number(await r.u62()); - await r.u62(); // ignore stream_count + const streamCount = await r.u62(); const reasonPhrase = await r.string(); - return new PublishDone({ requestId, statusCode, reasonPhrase }); + return new PublishDone({ requestId, statusCode, streamCount, reasonPhrase }); } } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index ad5cedf578..a16ce4ccbc 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -14,13 +14,14 @@ import { wireOf } from "../wire.ts"; import { NativeSession, type Session } from "./adapter.ts"; import type * as Cluster from "./cluster.ts"; import { FetchHeader } from "./fetch.ts"; -import { Group as GroupMessage } from "./object.ts"; +import { Frame, Group as GroupMessage } from "./object.ts"; import { PublishDone } from "./publish.ts"; import { PublishNamespace } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; import { RequestError, RequestOk } from "./request.ts"; import { Subscribe, SubscribeOk } from "./subscribe.ts"; import { SubscribeNamespace } from "./subscribe_namespace.ts"; +import { TrackStatusRequest } from "./track.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; function publish(origin: OriginProducer, path: Path.Valid) { @@ -129,6 +130,50 @@ function publisher( }; } +test("TRACK_STATUS gets exact NOT_SUPPORTED refusal bytes on every draft", async () => { + const phrase = new TextEncoder().encode("TRACK_STATUS is not supported"); + for (const version of [ + Version.DRAFT_14, + Version.DRAFT_15, + Version.DRAFT_16, + Version.DRAFT_17, + Version.DRAFT_18, + Version.DRAFT_19, + Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, + ] as const) { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, version, true); + const { pub, origin } = publisher(pair.server, { session }); + const written: Uint8Array[] = []; + const stream = new Stream({ + readable: new ReadableStream(), + writable: new WritableStream({ + write: (chunk) => { + written.push(new Uint8Array(chunk)); + }, + }), + version, + }); + await pub.runTrackStatusRequest( + new TrackStatusRequest({ requestId: 7n, trackNamespace: Path.from("test"), trackName: "video" }), + stream, + ); + await stream.writer.closed; + const body = [ + ...(version <= Version.DRAFT_16 ? [7] : []), + 3, + ...(version >= Version.DRAFT_16 ? [0] : []), + phrase.length, + ...phrase, + ]; + const expected = [version === Version.DRAFT_14 ? 0x0f : 0x05, 0, body.length, ...body]; + expect(written.flatMap((chunk) => Array.from(chunk))).toEqual(expected); + origin.close(); + } +}); + // The header is part of the group's lifetime too. If it blocks on flow control, advancing // the live edge must reset the stream without waiting for that write to finish. test("a blocked group header is reset when the group expires", async () => { @@ -863,6 +908,16 @@ async function readGroup(stream: ReadableStream): Promise): Promise { + const reader = new Reader(stream, undefined, V20); + const header = await GroupMessage.decode(reader, V20); + const frame = await Frame.decode(reader, header.flags, undefined, V20); + expect(frame.endOfTrack).toBe(true); + expect(await reader.done()).toBe(true); + return header.groupId; +} + /** * Read a fill's fetch stream to its end, reporting a reset rather than throwing. * @@ -1294,8 +1349,12 @@ test("draft-20: a clean close past a bounded filter's end still sends PUBLISH_DO expect(await client.reader.u53()).toBe(PublishDone.id); const done = await PublishDone.decode(client.reader, V20); expect(done.statusCode).toBe(TRACK_ENDED_STATUS); + expect(done.streamCount).toBe(2n); - // Only the in-range group was ever opened. + // Only the in-range group was ever served; the other stream marks the track's end. + const end = await nextUni(fx.uni); + if (!end) throw new Error("the track's end was never marked"); + expect(await readEndOfTrack(end)).toBe(2); expect(await nextUni(fx.uni)).toBeUndefined(); } finally { fx.close(); @@ -1409,3 +1468,61 @@ test("draft-20: a fill works on a dynamically requested track", async () => { client.close(); } }); + +// PUBLISH_DONE MUST wait until every stream the subscription will open is closed, so its +// Stream Count is final. A group still queued for a stream slot when the track ends is one. +test("draft-20: PUBLISH_DONE waits for a queued group and counts every stream", async () => { + const fx = fixture(); + const track = fx.broadcast.createTrack("video"); + + // Park the first stream open, the way a transport at its stream cap does. + const slot = Promise.withResolvers(); + const create = fx.pair.server.createUnidirectionalStream.bind(fx.pair.server); + let parked = false; + fx.pair.server.createUnidirectionalStream = async (options?: WebTransportSendStreamOptions) => { + if (!parked) { + parked = true; + await slot.promise; + } + return create(options); + }; + + const { client } = await runSubscribe( + fx, + new Subscribe({ + requestId: 7n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + filter: { kind: "absolute", startGroup: 0n, startObject: 0n }, + }), + ); + + try { + writeGroup(track, 1); + track.close(); + + // Nothing ends the subscription while the group waits for its slot. + const response = client.reader.u53(); + const idle = new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)); + expect(await Promise.race([response, idle])).toBe("pending"); + + slot.resolve(); + const served = await nextUni(fx.uni); + if (!served) throw new Error("the queued group was never served"); + expect((await readGroup(served)).sequence).toBe(0); + + expect(await response).toBe(PublishDone.id); + const done = await PublishDone.decode(client.reader, V20); + expect(done.statusCode).toBe(TRACK_ENDED_STATUS); + // The group's stream and the END_OF_TRACK marker's. + expect(done.streamCount).toBe(2n); + + const end = await nextUni(fx.uni); + if (!end) throw new Error("the track's end was never marked"); + expect(await readEndOfTrack(end)).toBe(1); + } finally { + fx.close(); + client.close(); + } +}); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index fc358b342a..a14dbb4ce2 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,13 +1,13 @@ -import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import type * as broadcast from "../broadcast.ts"; import { controlTimeout, error, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Route, routesEqual } from "../hop.ts"; -import { hooks } from "../internal.ts"; +import { hiddenBelow, hooks } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; -import { Milli, type Timescale } from "../time.ts"; +import { Milli, Timescale } from "../time.ts"; import type { Subscriber as TrackSubscriber } from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; @@ -20,7 +20,7 @@ import * as Filter from "./filter.ts"; import { FetchFrame, Frame, Group as GroupMessage } from "./object.ts"; import { fromWire, toWire } from "./priority.ts"; import * as Properties from "./properties.ts"; -import { PublishDone } from "./publish.ts"; +import { PublishDone, PublishDoneStatus } from "./publish.ts"; import { PublishNamespace, PublishNamespaceDone, PublishNamespaceOk } from "./publish_namespace.ts"; import { RequestError, RequestOk } from "./request.ts"; import { type Subscribe, SubscribeError, SubscribeOk } from "./subscribe.ts"; @@ -30,7 +30,7 @@ import { SubscribeNamespaceEntryDone, SubscribeNamespaceOk, } from "./subscribe_namespace.ts"; -import { TrackStatus, type TrackStatusRequest } from "./track.ts"; +import type { TrackStatusRequest } from "./track.ts"; import { type IetfVersion, Version } from "./version.ts"; /** First wait before re-offering a namespace the peer refused or we couldn't open for. */ @@ -52,12 +52,6 @@ function sameAdvert(a: Advertised | undefined, b: Advertised | undefined): boole return a !== undefined && b !== undefined && a.identity === b.identity && routesEqual(a.route, b.route); } -/** PUBLISH_DONE statuses this implementation emits. Stable across drafts 14 through 19. */ -const PUBLISH_DONE_STATUS = { - INTERNAL_ERROR: 0x0, - TRACK_ENDED: 0x2, -} as const; - /** * How long one advertisement may take to be answered. Matches the Rust publisher, and the * peer accepting the stream is only half the exchange: one it never answers on holds the @@ -108,8 +102,14 @@ interface RunGroup { /** Settles when the subscriber leaves, dropping a group still queued for a stream slot. */ unsubscribed: Promise; + + /** The subscription's data stream count, which PUBLISH_DONE reports. */ + streams: StreamCount; } +/** How many data streams a subscription opened, fill streams included. */ +type StreamCount = { opened: number }; + /** What {@link Publisher.runFill} needs to serve one subscription's backfill. */ interface RunFill { /** The subscription's request ID, which the fetch stream names. */ @@ -141,6 +141,9 @@ interface RunFill { /** Settles when the subscriber leaves, releasing a fill still waiting on its group. */ unsubscribed: Promise; + + /** The subscription's data stream count, which PUBLISH_DONE reports. */ + streams: StreamCount; } /** @@ -325,7 +328,7 @@ export class Publisher { ? // Declaring the timescale is what opts the track into timestamps; every // object Timestamp below is in these units. We serve the newest group // first, matching moq-lite. - { timescale, groupOrder: Properties.DESCENDING } + { timescale, priority: publisherPriority, groupOrder: Properties.DESCENDING } : // INCLUDE_PROPERTIES=0. The block stays present but empty, which also means // the track opts out of timestamps for this subscriber. {}, @@ -349,6 +352,11 @@ export class Publisher { () => unsubscribe(), ); + // Every group started, until its stream finishes or resets, and the data streams + // opened for PUBLISH_DONE to report. + const groups = new Set>(); + const streams: StreamCount = { opened: 0 }; + // Serve track groups, racing with stream close (= Unsubscribe) const serving = (async () => { for (;;) { @@ -365,7 +373,7 @@ export class Publisher { continue; } - void this.#runGroup({ + const task = this.#runGroup({ requestId: msg.requestId, group, timescale, @@ -373,7 +381,10 @@ export class Publisher { stamped: msg.propertiesWanted, slice: groupSlice(range, group.sequence), unsubscribed, + streams, }); + groups.add(task); + void task.finally(() => groups.delete(task)); } })(); @@ -389,16 +400,38 @@ export class Publisher { timescale, stamped: msg.propertiesWanted, unsubscribed, + streams, }) : Promise.resolve(); let publishError: Error | undefined; + let ended = false; try { - await Promise.race([Promise.all([serving, filling]), stream.reader.closed]); + const served = Symbol("served"); + ended = + (await race([Promise.all([serving, filling]).then(() => served), stream.reader.closed])) === served; } catch (err: unknown) { publishError = error(err); } + // PUBLISH_DONE waits until every stream this subscription will open is closed, as + // the draft requires, so its count is final. The subscriber leaving cancels the + // ones still queued instead. + await race([Promise.all(groups), unsubscribed]); + + // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what + // tells the subscriber where the track ended. + const final = track.final(); + if (ended && !publishError && final !== undefined) { + await this.#runEndOfTrack({ + requestId: msg.requestId, + final, + publisherPriority, + unsubscribed, + streams, + }); + } + console.debug(`publish done: broadcast=${name} track=${track.name}`); if (publishError) { console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(publishError)}`); @@ -413,7 +446,8 @@ export class Publisher { version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED, + statusCode: publishError ? PublishDoneStatus.INTERNAL_ERROR : PublishDoneStatus.TRACK_ENDED, + streamCount: BigInt(streams.opened), reasonPhrase: publishError ? "internal error" : "track ended", }); await done.encode(stream.writer, version); @@ -442,7 +476,7 @@ export class Publisher { * Runs a group and sends its frames using ObjectStream (Subgroup delivery mode). */ async #runGroup(options: RunGroup) { - const { requestId, group, timescale, publisherPriority, stamped, slice, unsubscribed } = options; + const { requestId, group, timescale, publisherPriority, stamped, slice, unsubscribed, streams } = options; try { // One stream per group is faster than a peer at its limit can retire them, so this // is the one path that doesn't wait for a slot: the transport would serve the opens @@ -457,6 +491,7 @@ export class Publisher { group.close(new Error("no stream slot")); return; } + streams.opened += 1; const header = new GroupMessage({ trackAlias: requestId, @@ -495,7 +530,7 @@ export class Publisher { // Reading from the filter's start drops the objects below it: they are outside // the requested range, so skipping them is not a gap. - const read = await Promise.race([hooks.readGroupFrame(group, slice.skip), stream.closed]); + const read = await race([hooks.readGroupFrame(group, slice.skip), stream.closed]); if (!read) break; next = read.sequence + 1; if (slice.until !== undefined && read.sequence >= slice.until) { @@ -525,6 +560,49 @@ export class Publisher { } } + /** + * Mark the track's end with an END_OF_TRACK object on its own stream, at object 0 of the + * group that will never exist. + * + * The last group's stream has usually finished before the track ends, so the marker cannot + * ride on it. A failure only costs the subscriber the early boundary. + */ + async #runEndOfTrack(options: { + requestId: bigint; + final: number; + publisherPriority: number; + unsubscribed: Promise; + streams: StreamCount; + }) { + const { requestId, final, publisherPriority, unsubscribed, streams } = options; + const version = this.#session.version; + const stream = await Writer.tryOpen(this.#quic, { cancel: unsubscribed, version }).catch(() => undefined); + if (!stream) return; + streams.opened += 1; + + try { + const header = new GroupMessage({ + trackAlias: requestId, + groupId: final, + subGroupId: 0, + publisherPriority, + flags: { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: false, + hasPriority: true, + firstObject: true, + }, + }); + await header.encode(stream, version); + await new Frame({ endOfTrack: true }).encode(stream, header.flags, Timescale.MILLI, version); + stream.close(); + } catch (err: unknown) { + stream.reset(error(err)); + } + } + /** * Serve a draft-20 fill on its own fetch stream: the requested range, read from the * group cache, capped at the Largest Object snapshot. @@ -534,7 +612,7 @@ export class Publisher { * fill-failure signal. Nothing here touches the subscription either way. */ async #runFill(options: RunFill) { - const { requestId, fill, cache, timescale, stamped, unsubscribed } = options; + const { requestId, fill, cache, timescale, stamped, unsubscribed, streams } = options; const version = this.#session.version; // Everything is inside the try so the cache fork is released on every path out, @@ -548,6 +626,7 @@ export class Publisher { console.debug(`fill stream failed to open: fill=${requestId}`); return; } + streams.opened += 1; await stream.u53(FetchHeader.type); await new FetchHeader({ requestId }).encode(stream, version); @@ -608,11 +687,7 @@ export class Publisher { if (fill.until !== undefined && next >= fill.until) break; // Reading from the fill's start drops everything below it; see the same read in #runGroup. - const frame = await Promise.race([ - group.readFrameSequence({ from: Number(fill.skip) }), - stream.closed, - cancelled, - ]); + const frame = await race([group.readFrameSequence({ from: Number(fill.skip) }), stream.closed, cancelled]); if (left) throw new Error("unsubscribed before the fill finished"); if (!frame) break; next = BigInt(frame.sequence) + 1n; @@ -632,10 +707,10 @@ export class Publisher { /** * Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream. * - * This carries the advertisements only when the peer asked to be told on request - * (MoQ Solicit); otherwise {@link runPublishNamespaces} has already announced - * everything and repeating it here would leave the peer holding two sources for one - * broadcast. Draft-16+ streams Namespace entries inline; draft-14/15 predate those + * This carries the advertisements when the peer asked to be told on request (MoQ + * Solicit); otherwise {@link runPublishNamespaces} has already announced everything + * visible and repeating it here would leave the peer holding two sources for one + * broadcast, so only the hidden namespaces it may see ride here (MoQ Hidden). Draft-16+ streams Namespace entries inline; draft-14/15 predate those * messages, so each advertisement is a PUBLISH_NAMESPACE request of its own. * * @internal @@ -662,12 +737,12 @@ export class Publisher { await ok.encode(stream.writer, version); } - if (!this.#requiresSolicitation) { - // Already announced, unasked. Hold the stream open until the peer is done. - await stream.reader.closed; - stream.close(); - return; - } + // Hidden namespaces are left out unless the peer opted in (MoQ Hidden). Unless the + // peer asked to be told only on request, it has already heard everything visible + // from the empty prefix unasked, so this stream carries only what that hid. + const carries = (covered: Path.Valid) => + (msg.hidden || !hiddenBelow(prefix, covered)) && + (this.#requiresSolicitation || hiddenBelow(Path.empty(), covered)); // Reports whether the peer now holds the namespace: an inline entry always // lands, but a PUBLISH_NAMESPACE request can be declined. @@ -718,7 +793,7 @@ export class Publisher { const updated = new Map(); for (const [covered, snap] of advertised) { const suffix = Path.stripPrefix(prefix, covered); - if (suffix === null) continue; + if (suffix === null || !carries(covered)) continue; updated.set(suffix, snap); } @@ -767,8 +842,8 @@ export class Publisher { // Wait for the next change, or for the peer to unsubscribe. const next = await (retry - ? Promise.race([changed, stream.reader.closed, retryAfter(retry).then(() => advertised)]) - : Promise.race([changed, stream.reader.closed])); + ? race([changed, stream.reader.closed, retryAfter(retry).then(() => advertised)]) + : race([changed, stream.reader.closed])); dispose(); if (!next) break; } @@ -847,6 +922,8 @@ export class Publisher { const updated = new Map(); for (const [covered, snap] of advertised) { + // Unasked, a hidden namespace stays off the wire (MoQ Hidden). + if (hiddenBelow(Path.empty(), covered)) continue; updated.set(covered, snap); } @@ -893,8 +970,8 @@ export class Publisher { // Wait for the next change, which has already fired if one landed above. const next = await (retry - ? Promise.race([changed, closed, retryAfter(retry).then(() => advertised)]) - : Promise.race([changed, closed])); + ? race([changed, closed, retryAfter(retry).then(() => advertised)]) + : race([changed, closed])); dispose?.(); if (!next) break; } @@ -1061,25 +1138,22 @@ export class Publisher { */ async runTrackStatusRequest(msg: TrackStatusRequest, stream: Stream) { const version = this.#session.version; - + const errorCode = toRequestCode("not_supported", "track_status", version); if (version === Version.DRAFT_14) { - // v14: respond with TrackStatus (0x0E = TRACK_STATUS_OK) - await stream.writer.u53(TrackStatus.id); - const status = new TrackStatus({ - trackNamespace: msg.trackNamespace, - trackName: msg.trackName, - statusCode: TrackStatus.STATUS_NOT_FOUND, - lastGroupId: 0n, - lastObjectId: 0n, - }); - await status.encode(stream.writer, version); + // TRACK_STATUS_ERROR shares the SUBSCRIBE_ERROR body on draft-14. + await stream.writer.u53(0x0f); + await new SubscribeError({ + requestId: msg.requestId, + errorCode, + reasonPhrase: "TRACK_STATUS is not supported", + }).encode(stream.writer, version); } else { - // v15+: respond with RequestOk (0x07) - await stream.writer.u53(RequestOk.id); - const ok = new RequestOk({ + await stream.writer.u53(RequestError.id); + await new RequestError({ requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - }); - await ok.encode(stream.writer, version); + errorCode, + reasonPhrase: "TRACK_STATUS is not supported", + }).encode(stream.writer, version); } stream.close(); } diff --git a/js/net/src/ietf/subscribe_namespace.ts b/js/net/src/ietf/subscribe_namespace.ts index 6d7944726f..25f5054a71 100644 --- a/js/net/src/ietf/subscribe_namespace.ts +++ b/js/net/src/ietf/subscribe_namespace.ts @@ -30,10 +30,17 @@ export class SubscribeNamespace { namespace: Path.Valid; requestId: bigint; + /** MoQ Hidden: also advertise hidden namespaces. Only sent to a peer that declared it. */ + hidden: boolean; - constructor({ namespace, requestId }: { namespace: Path.Valid; requestId: bigint }) { + constructor({ + namespace, + requestId, + hidden = false, + }: { namespace: Path.Valid; requestId: bigint; hidden?: boolean }) { this.namespace = namespace; this.requestId = requestId; + this.hidden = hidden; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -42,7 +49,9 @@ export class SubscribeNamespace { } await w.u62(this.requestId); await Namespace.encode(w, this.namespace); - await new Parameters().encode(w, version); + const params = new Parameters(); + params.hidden = this.hidden; + await params.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -59,9 +68,9 @@ export class SubscribeNamespace { } const requestId = await r.u62(); const namespace = await Namespace.decode(r); - await Parameters.decode(r, version); + const params = await Parameters.decode(r, version); - return new SubscribeNamespace({ namespace, requestId }); + return new SubscribeNamespace({ namespace, requestId, hidden: params.hidden }); } } @@ -77,19 +86,24 @@ export class SubscribeNamespaceLegacy { namespace: Path.Valid; requestId: bigint; subscribeOptions: number; // v16/v17: default 0x01 (NAMESPACE only) + /** MoQ Hidden: see {@link SubscribeNamespace.hidden}. */ + hidden: boolean; constructor({ namespace, requestId, subscribeOptions = 1, + hidden = false, }: { namespace: Path.Valid; requestId: bigint; subscribeOptions?: number; + hidden?: boolean; }) { this.namespace = namespace; this.requestId = requestId; this.subscribeOptions = subscribeOptions; + this.hidden = hidden; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -104,7 +118,9 @@ export class SubscribeNamespaceLegacy { if (version === Version.DRAFT_16 || version === Version.DRAFT_17) { await w.u53(this.subscribeOptions); } - await new Parameters().encode(w, version); + const params = new Parameters(); + params.hidden = this.hidden; + await params.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -128,9 +144,9 @@ export class SubscribeNamespaceLegacy { if (version === Version.DRAFT_16 || version === Version.DRAFT_17) { subscribeOptions = await r.u53(); } - await Parameters.decode(r, version); + const params = await Parameters.decode(r, version); - return new SubscribeNamespaceLegacy({ namespace, requestId, subscribeOptions }); + return new SubscribeNamespaceLegacy({ namespace, requestId, subscribeOptions, hidden: params.hidden }); } } diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 6e40f92103..a0f560d42a 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -840,6 +840,82 @@ async function subscribeTrack(): Promise<{ subscriber: Subscriber; track: track. return { subscriber, track }; } +test("older peer without priority property inherits wire priority 128", async () => { + const { subscriber, track } = await subscribeTrack(); + expect((await track.info()).priority).toBe(0xff - 128); + const group = new GroupMessage({ + trackAlias: ALIAS, + groupId: 3, + subGroupId: 0, + publisherPriority: 0, + flags: { ...groupFlags(true), hasPriority: false }, + }); + await subscriber.handleGroup(group, new Reader(undefined, encodeObjects([0]), VERSION)); + expect(group.publisherPriority).toBe(128); + track.close(); +}); + +test("an info-only lookup waits for SUBSCRIBE_OK instead of abandoning", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session }); + const info = subscriber.consume(Path.from("room")).track("video").info(); + const peer = await nextStream(pair.client); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + expect(await peer.reader.u53()).toBe(Subscribe.id); + const request = await Subscribe.decode(peer.reader, VERSION); + + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ + requestId: request.requestId, + trackAlias: ALIAS, + properties: { priority: 37 }, + }).encode(peer.writer, VERSION); + expect((await info).priority).toBe(0xff - 37); +}); + +test("early group waits for SUBSCRIBE_OK priority before track acceptance", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session }); + const track = subscriber.consume(Path.from("room")).track("video").subscribe(); + const peer = await nextStream(pair.client); + if (!peer) throw new Error("missing SUBSCRIBE stream"); + expect(await peer.reader.u53()).toBe(Subscribe.id); + const request = await Subscribe.decode(peer.reader, VERSION); + + const flags = { ...groupFlags(true), hasPriority: false }; + const group = new GroupMessage({ trackAlias: ALIAS, groupId: 3, subGroupId: 0, publisherPriority: 0, flags }); + const arriving = subscriber.handleGroup(group, new Reader(undefined, encodeObjects([0]), VERSION)); + const pending = await Promise.race([arriving.then(() => false), Promise.resolve(true)]); + expect(pending).toBe(true); + + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ + requestId: request.requestId, + trackAlias: ALIAS, + properties: { priority: 37 }, + }).encode(peer.writer, VERSION); + await arriving; + expect((await track.info()).priority).toBe(0xff - 37); + expect(group.publisherPriority).toBe(37); + const ordered = track.ordered(); + expect((await ordered.nextGroup())?.sequence).toBe(3); + const explicit = new GroupMessage({ + trackAlias: ALIAS, + groupId: 4, + subGroupId: 0, + publisherPriority: 9, + flags: groupFlags(true), + }); + await subscriber.handleGroup(explicit, new Reader(undefined, encodeObjects([0]), VERSION)); + expect(explicit.publisherPriority).toBe(9); + expect((await track.info()).priority).toBe(0xff - 37); + expect((await ordered.nextGroup())?.sequence).toBe(4); + ordered.close(); + track.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, diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 24dc891ec6..b107b0ca23 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1,12 +1,14 @@ +import { race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; import * as broadcast from "../broadcast.ts"; import { BroadcastCache } from "../consume.ts"; import { controlTimeout, error, ProtocolViolation, reason } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; -import { scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; +import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; import type { Reader, Stream } from "../stream.ts"; +import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import { type Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; @@ -16,8 +18,8 @@ import { DuplicateTrackAlias, RetiredTrackAlias, TrackAliases } from "./aliases. import * as Cluster from "./cluster.ts"; import { requestReason, toRequestCode } from "./error.ts"; import { Frame, type Group as GroupMessage } from "./object.ts"; -import { toWire } from "./priority.ts"; -import { type Publish, PublishError } from "./publish.ts"; +import { fromWire, toWire } from "./priority.ts"; +import { type Publish, PublishDone, PublishError, publishDoneClean } from "./publish.ts"; import { type PublishNamespace, PublishNamespaceDone, @@ -43,6 +45,15 @@ import { Version } from "./version.ts"; // blocks. The timeout turns that into a clear error. const SUBSCRIBE_OK_TIMEOUT_MS = 10_000; +// A live subscription, as the track alias its data streams name resolves to. +type Subscription = { + // The write side incoming group streams are routed into. + track: track.Producer; + // The group streams received, so the subscription can wait for the ones PUBLISH_DONE + // says are still owed. + tail: Tail; +}; + // 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. @@ -69,6 +80,14 @@ type SubscribeSetupState = { rejected?: boolean; }; +/** 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 }; + +/** Whether a reader with `filter` sees an announcement at `path`. */ +function sees(filter: Filter, path: Path.Valid): boolean { + return scopeOverlaps(filter.scope, path) && (filter.hidden || !hiddenBelow(filter.prefix, path)); +} + /** * Handles subscribing to broadcasts using moq-transport protocol. * Uses the stream-per-request pattern (real bidi streams for v17, virtual for v14-v16). @@ -84,7 +103,7 @@ export class Subscriber { #cluster?: Cluster.Hops; // Publisher-chosen aliases used by incoming group streams. - #aliases = new TrackAliases(); + #aliases = new TrackAliases(); // Units for each track's object Timestamps, from the TIMESCALE Track Property in // SUBSCRIBE_OK. A track missing from this map declared no timeline, so the publisher @@ -109,7 +128,10 @@ export class Subscriber { #announced = new Map(); // Any consumers that want each new announcement, keyed by their local filter. - #announcedConsumers = new Map(); + #announcedConsumers = new Map(); + + // Whether the peer understands the HIDDEN parameter (MoQ Hidden). + #hidden: boolean; /** * Creates a new Subscriber instance. @@ -119,14 +141,18 @@ export class Subscriber { constructor({ session, cluster, + hidden = false, }: { /** The session abstraction for bidi streams and request IDs. */ session: Session; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; + /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ + hidden?: boolean; }) { this.#session = session; this.#cluster = cluster; + this.#hidden = hidden; } /** @@ -154,13 +180,19 @@ export class Subscriber { * The peer is asked with SUBSCRIBE_NAMESPACE regardless of what it declared, and an * unsolicited PUBLISH_NAMESPACE lands here too, so a peer that only tells and one * that only answers are both discovered. + * + * Hidden routes (a `.`-prefixed segment below the scope's head) are left out unless + * `options.hidden` opts in. The opt-in rides the SUBSCRIBE_NAMESPACE when the peer + * understands it (MoQ Hidden); the rule is also applied here, since an unsolicited + * PUBLISH_NAMESPACE or a peer that never heard of it hides nothing. */ - announced(scope: Path.Pattern = Path.Pattern.all()): announce.Consumer { + announced(scope: Path.Pattern = Path.Pattern.all(), options?: announce.Options): announce.Consumer { // The wire speaks announce interest by prefix. const prefix = scopeHead(scope); + const filter = { scope, prefix, hidden: options?.hidden ?? false }; const announced = new announce.Producer(); for (const [active, info] of this.#announced) { - if (!scopeOverlaps(scope, active)) continue; + if (!sees(filter, active)) continue; announced.append({ prefix: active, captures: scopeCaptures(scope, active), @@ -168,9 +200,9 @@ export class Subscriber { route: info.route, }); } - this.#announcedConsumers.set(announced, scope); + this.#announcedConsumers.set(announced, filter); - void this.#runAnnounced(announced, prefix).finally(() => { + void this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden).finally(() => { this.#announcedConsumers.delete(announced); announced.close(); }); @@ -191,8 +223,9 @@ export class Subscriber { this.#announced.set(path, { count: 1, route }); console.debug(`announced: broadcast=${path} active=true`); - for (const [consumer, scope] of this.#announcedConsumers) { - if (!scopeOverlaps(scope, path)) continue; + for (const [consumer, filter] of this.#announcedConsumers) { + if (!sees(filter, path)) continue; + const scope = filter.scope; consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "announced", route }); } } @@ -207,8 +240,9 @@ export class Subscriber { if (existing === undefined || routesEqual(existing.route, route)) return; existing.route = route; console.debug(`announced: broadcast=${path} rerouted`); - for (const [consumer, scope] of this.#announcedConsumers) { - if (!scopeOverlaps(scope, path)) continue; + for (const [consumer, filter] of this.#announcedConsumers) { + if (!sees(filter, path)) continue; + const scope = filter.scope; consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "updated", route }); } } @@ -231,8 +265,9 @@ export class Subscriber { this.#consumes.evict(path); console.debug(`announced: broadcast=${path} active=false`); - for (const [consumer, scope] of this.#announcedConsumers) { - if (!scopeOverlaps(scope, path)) continue; + for (const [consumer, filter] of this.#announcedConsumers) { + if (!sees(filter, path)) continue; + const scope = filter.scope; try { consumer.append({ prefix: path, @@ -246,7 +281,7 @@ export class Subscriber { } } - async #runAnnounced(announced: announce.Producer, prefix: Path.Valid) { + async #runAnnounced(announced: announce.Producer, prefix: Path.Valid, hidden: boolean) { const version = this.#session.version; // Suffixes live on this stream, so a repeat is recognized as an update to the @@ -283,10 +318,16 @@ export class Subscriber { version === Version.DRAFT_17 ) { await stream.writer.u53(SubscribeNamespaceLegacy.id); - await new SubscribeNamespaceLegacy({ namespace: prefix, requestId }).encode(stream.writer, version); + await new SubscribeNamespaceLegacy({ namespace: prefix, requestId, hidden }).encode( + stream.writer, + version, + ); } else { await stream.writer.u53(SubscribeNamespace.id); - await new SubscribeNamespace({ namespace: prefix, requestId }).encode(stream.writer, version); + await new SubscribeNamespace({ namespace: prefix, requestId, hidden }).encode( + stream.writer, + version, + ); } console.debug(`subscribe_namespace written: requestId=${requestId}`); @@ -365,7 +406,7 @@ export class Subscriber { }); // Wait for either the read loop or the announced to close - await Promise.race([readLoop, announced.closed]); + await race([readLoop, announced.closed]); // For v14/v15: send UnsubscribeNamespace before closing if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { @@ -447,23 +488,26 @@ export class Subscriber { console.debug(`subscribe start: id=${requestId} broadcast=${broadcast} track=${request.name}`); - // IETF negotiates group order in SUBSCRIBE_OK; this implementation only supports - // descending (newest-first), which is what moq-lite fixes group order to, so the - // mapping needs nothing here. (There's no per-frame timescale either, so every - // property stays at its default.) This resolves the consumer's track.info() and - // gives us the write side that incoming object streams are routed into. - const producer = request.accept({}); + // 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. + const producer = hooks.pendingTrackProducer(request); + const subscription: Subscription = { track: producer, tail: new Tail() }; // 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. const state: SubscribeSetupState = {}; - const setup = this.#openSubscribe(state, broadcast, request, producer, requestId); + const setup = this.#openSubscribe(state, broadcast, request, subscription, requestId); // The publisher can be serving before it answers, so waiting only on the response // would miss the local side going away and leave it serving a track nobody reads. // Demand returning before we commit is not abandonment, matching the serving loop. const waitAbandoned = async (): Promise => { + // An info-only lookup attaches no subscriber yet still waits on SUBSCRIBE_OK for + // the track info, so only demand that arrived and then left is abandonment. + while (!producer.used.peek() && producer.closed.peek() === undefined) { + await Signal.race(producer.used, producer.closed); + } for (;;) { await producer.unused(); if (producer.closed.peek() !== undefined || !producer.used.peek()) return null; @@ -473,7 +517,7 @@ export class Subscriber { let stream: Stream; let trackAlias: bigint; try { - const result = await Promise.race([ + const result = await race([ withTimeout( setup, SUBSCRIBE_OK_TIMEOUT_MS, @@ -490,7 +534,7 @@ export class Subscriber { } catch (err) { // A control request that timed out is not late content, so it carries its own code. const e = err instanceof TimeoutError ? controlTimeout(err) : error(err); - producer.close(e); + request.reject(e); console.warn( `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, ); @@ -501,7 +545,7 @@ export class Subscriber { const cleanup = async (afterSetup: boolean) => { state.cancelled = true; - if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, producer)) { + if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, subscription)) { this.#timescales.delete(state.registeredAlias); } @@ -542,10 +586,10 @@ export class Subscriber { const localEnded = Symbol("local"); const idle = Symbol("idle"); - // Terminal conditions settle at most once (stream close = PublishDone, track close = - // local unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. - const done = Promise.race([ - stream.reader.closed.then(() => publisherEnded), + // Terminal conditions settle at most once (PublishDone, track close = local + // unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. + const done = race([ + this.#runPublishDone(stream, subscription).then(() => publisherEnded), producer.closed.then(() => localEnded), ]); @@ -554,7 +598,7 @@ export class Subscriber { // down resumes on the same stream. let terminal = localEnded; for (;;) { - const reason = await Promise.race([done, producer.unused().then(() => idle)]); + const reason = await race([done, producer.unused().then(() => idle)]); if (reason === idle && producer.closed.peek() === undefined && producer.used.peek()) continue; terminal = reason; break; @@ -582,8 +626,37 @@ export class Subscriber { } finally { // Only the owner tears down the alias metadata: a later subscription may have // reclaimed the alias and installed its own timescale. - if (this.#aliases.retire(trackAlias, producer)) this.#timescales.delete(trackAlias); + if (this.#aliases.retire(trackAlias, subscription)) this.#timescales.delete(trackAlias); + } + } + + /** + * Read the PUBLISH_DONE that ends a subscription, then wait for the data streams it counts. + * + * An error status aborts the track with it. A clean one leaves streams in flight, since + * QUIC does not order them, so wait until the Stream Count many have been read, or a + * bounded grace for the ones that never arrive (the draft says to use a timeout). The count + * is only a hint: a peer may send 0 regardless, so 0 waits out the grace. A request stream + * that ends without one ends the track the same way. + */ + async #runPublishDone(stream: Stream, subscription: Subscription): Promise { + const version = this.#session.version; + let count: bigint | undefined; + if (!(await stream.reader.done())) { + const typeId = await stream.reader.u53(); + if (typeId !== PublishDone.id) { + throw new ProtocolViolation(`unexpected message on a subscription: 0x${typeId.toString(16)}`); + } + const done = await PublishDone.decode(stream.reader, version); + if (!publishDoneClean(done.statusCode, version)) { + throw new Error(`publish done: status=0x${done.statusCode.toString(16)} reason=${done.reasonPhrase}`); + } + count = done.streamCount; } + + const { tail, track } = subscription; + const complete = () => count !== undefined && count > 0n && BigInt(tail.streams) >= count; + await tail.settle(complete, TAIL_GRACE_MS, track.closed); } /** @@ -615,7 +688,7 @@ export class Subscriber { state: SubscribeSetupState, broadcast: Path.Valid, request: track.Request, - producer: track.Producer, + subscription: Subscription, requestId: bigint, ): Promise<{ stream: Stream; alias: bigint }> { const version = this.#session.version; @@ -669,9 +742,11 @@ export class Subscriber { } const ok = await SubscribeOk.decode(state.stream.reader, version); + if (state.cancelled) throw new Error("subscribe cancelled before acceptance"); + request.accept({ priority: fromWire(ok.properties.priority ?? 128) }); try { - this.#aliases.set(ok.trackAlias, producer, { broadcast, name: request.name }); + this.#aliases.set(ok.trackAlias, subscription, { broadcast, name: request.name }); const timescale = ok.properties.timescale; if (timescale !== undefined) { this.#timescales.set(ok.trackAlias, timescale); @@ -900,32 +975,62 @@ export class Subscriber { throw new Error("subgroups are not supported"); } - // FIRST_OBJECT clear says this stream starts partway through the group, which the - // draft lets a publisher do to answer a filter. Nothing above here can use it: the - // objects that would arrive are not decodable without the missing head, and a group - // is the unit an application resyncs on. Drop it and pick up at the next group, the - // same degradation as a publisher that no longer holds the head. - // - // This only saves reading a stream we would throw away. The bit is the publisher's - // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every - // object to starting at 0 and incrementing by 1, whatever the header said and on the - // drafts that have no such bit to read. - if (!group.flags.firstObject) { - console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); - stream.stop(new Error("a group must start at object 0")); + let subscription: Subscription; + try { + // The control message establishing this alias can arrive after the data stream. + subscription = await this.#aliases.get(group.trackAlias); + } catch (err: unknown) { + const e = error(err); + // Ours: we cancelled the subscription and the publisher has not stopped yet. + // Anything else on this alias is the publisher sending data for a track it never + // acknowledged, which is worth seeing. + if (e instanceof RetiredTrackAlias) { + console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`); + } + stream.stop(e); return; } - const producer = new netGroup.Producer(group.groupId); + const { track, tail } = subscription; + // Every data stream counts toward PUBLISH_DONE's Stream Count, even one dropped below. + const read = tail.open(group.groupId); + + // Created on the first object rather than the header: an END_OF_TRACK at object 0 + // means the group does not exist at all. + let producer: netGroup.Producer | undefined; + const open = () => { + if (!producer) { + producer = new netGroup.Producer(group.groupId); + track.writeGroup(producer); + } + return producer; + }; try { - // The control message establishing this alias can arrive after the data stream. - const track = await this.#aliases.get(group.trackAlias); + // FIRST_OBJECT clear says this stream starts partway through the group, which the + // draft lets a publisher do to answer a filter. Nothing above here can use it: the + // objects that would arrive are not decodable without the missing head, and a group + // is the unit an application resyncs on. Drop it and pick up at the next group, the + // same degradation as a publisher that no longer holds the head. + // + // This only saves reading a stream we would throw away. The bit is the publisher's + // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every + // object to starting at 0 and incrementing by 1, whatever the header said and on the + // drafts that have no such bit to read. + if (!group.flags.firstObject) { + console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); + stream.stop(new Error("a group must start at object 0")); + return; + } - track.writeGroup(producer); + // The alias binds after SUBSCRIBE_OK commits the track property; an omitted + // header priority inherits it (draft-21 section 10.4). + if (!group.flags.hasPriority) group.publisherPriority = toWire((await track.info()).priority); for (;;) { - const done = await Promise.race([stream.done(), producer.closed, track.closed]); + // Only the group's own stream ends it: a track that closes first has already + // closed (or aborted) this group through its cache. + const done = await (producer ? race([stream.done(), producer.closed]) : stream.done()); if (done !== false) break; const frame = await Frame.decode( @@ -934,22 +1039,44 @@ export class Subscriber { this.#timescales.get(group.trackAlias), this.#session.version, ); + + if (frame.endOfTrack) { + // 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. + const end = producer ? group.groupId + 1 : group.groupId; + producer?.close(); + try { + track.finishAt(end); + } catch (err: unknown) { + throw new ProtocolViolation(`invalid END_OF_TRACK: ${reason(error(err))}`); + } + return; + } if (frame.payload === undefined) break; - producer.writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() }); + open().writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() }); } - producer.close(); + // A group with no objects still exists. + open().close(); } catch (err: unknown) { const e = error(err); - // Ours: we cancelled the subscription and the publisher has not stopped yet. - // Anything else on this alias is the publisher sending data for a track it never - // acknowledged, which is worth seeing. - if (e instanceof RetiredTrackAlias) { - console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`); + if (e instanceof ProtocolViolation) { + // The publisher broke the track's end, which no later group can repair. + producer?.close(e); + track.close(e); + } else { + // A stream that fails before its first object still names a group, which the + // reader sees fail rather than silently go missing. + try { + open().close(e); + } catch { + // The track has already closed or ended below this group. + } } - producer.close(e); stream.stop(e); + } finally { + read(); } } } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts new file mode 100644 index 0000000000..d7a505f642 --- /dev/null +++ b/js/net/src/ietf/tail.test.ts @@ -0,0 +1,177 @@ +import { expect, test } from "bun:test"; +import type { Consumer as GroupConsumer } from "../group.ts"; +import { createMockTransportPair } from "../mock.ts"; +import * as Path from "../path.ts"; +import { Reader, Stream } from "../stream.ts"; +import { TAIL_GRACE_MS } from "../tail.ts"; +import { Milli } from "../time.ts"; +import { NativeSession } from "./adapter.ts"; +import { type GroupFlags, Group as GroupMessage } from "./object.ts"; +import { PublishDone } from "./publish.ts"; +import { Subscribe, SubscribeOk } from "./subscribe.ts"; +import { Subscriber } from "./subscriber.ts"; +import { ALPN, Version } from "./version.ts"; + +const VERSION = Version.DRAFT_19; +const ALIAS = 9n; +const TRACK_ENDED = 0x2; +const INTERNAL_ERROR = 0x0; + +// A plain subgroup stream: no extensions, no subgroup id, end of group on FIN. +const FLAGS: GroupFlags = { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, +}; + +/** One object with a zero id delta. Every field is under 64, so each is a one-byte varint. */ +function object(payload: string): Uint8Array { + const bytes = new TextEncoder().encode(payload); + return new Uint8Array([0, bytes.byteLength, ...bytes]); +} + +/** An END_OF_TRACK object: zero length, then status 0x4. */ +const END_OF_TRACK = new Uint8Array([0, 0, 0x4]); + +/** A group stream the test writes by hand, handed to the subscriber as if it arrived. */ +function groupStream(subscriber: Subscriber, groupId: number) { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ start: (c) => (controller = c) }); + const header = new GroupMessage({ trackAlias: ALIAS, groupId, subGroupId: 0, publisherPriority: 0, flags: FLAGS }); + const handled = subscriber.handleGroup(header, new Reader(readable, undefined, VERSION)); + return { + write: (bytes: Uint8Array) => controller.enqueue(bytes), + finish: () => controller.close(), + handled, + }; +} + +/** A subscriber with one track subscribed and answered; the test plays the publisher. */ +async function subscribed() { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session }); + const reader = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ maxAge: Milli(60_000) }); + + const peer = await Stream.accept(pair.client, VERSION); + if (!peer) throw new Error("the subscriber never opened a subscribe stream"); + expect(await peer.reader.u53()).toBe(Subscribe.id); + const request = await Subscribe.decode(peer.reader, VERSION); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, VERSION); + + return { + subscriber, + reader, + done: async (statusCode: number, streamCount: bigint) => { + await peer.writer.u53(PublishDone.id); + await new PublishDone({ statusCode, streamCount, reasonPhrase: "done" }).encode(peer.writer, VERSION); + peer.writer.close(); + }, + }; +} + +async function readAll(group: GroupConsumer | undefined): Promise { + if (!group) throw new Error("no group"); + const out: string[] = []; + for (;;) { + const next = await group.readString(); + if (next === undefined) return out; + out.push(next); + } +} + +test("a group stream that arrives after PUBLISH_DONE is delivered", async () => { + const { subscriber, reader, done } = await subscribed(); + const first = groupStream(subscriber, 0); + first.write(object("0.0")); + first.finish(); + await first.handled; + await done(TRACK_ENDED, 2n); + + // QUIC does not order streams, so the second one lands after PUBLISH_DONE. + const started = performance.now(); + const late = groupStream(subscriber, 1); + late.write(object("1.0")); + late.finish(); + + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + // The Stream Count was met, so nothing waited out the grace. + expect(performance.now() - started).toBeLessThan(TAIL_GRACE_MS); +}); + +test("a group read across PUBLISH_DONE is delivered whole", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 0); + group.write(object("0.0")); + await done(TRACK_ENDED, 1n); + + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + group.write(object("0.1")); + group.finish(); + expect(await readAll(received)).toEqual(["0.1"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); +}); + +test("a Stream Count of 0 is a hint, so a late stream within the grace is still delivered", async () => { + const { subscriber, reader, done } = await subscribed(); + const started = performance.now(); + await done(TRACK_ENDED, 0n); + + const late = groupStream(subscriber, 0); + late.write(object("0.0")); + late.finish(); + + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + expect(performance.now() - started).toBeGreaterThanOrEqual(TAIL_GRACE_MS - 5); +}); + +test("a PUBLISH_DONE with an error status aborts the track", async () => { + const { reader, done } = await subscribed(); + await done(INTERNAL_ERROR, 0n); + expect(await reader.closed).toBeInstanceOf(Error); +}); + +test("END_OF_TRACK after a group's last object ends the track after that group", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 4); + group.write(object("4.0")); + group.write(END_OF_TRACK); + group.finish(); + + expect(await reader.finished()).toBe(5); + expect(await readAll(await reader.recvGroup())).toEqual(["4.0"]); + + await done(TRACK_ENDED, 1n); + expect(await reader.recvGroup()).toBeUndefined(); + expect(reader.final()).toBe(5); +}); + +test("END_OF_TRACK at object 0 ends the track before its group, which never exists", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 0); + group.write(object("0.0")); + group.finish(); + const end = groupStream(subscriber, 2); + end.write(END_OF_TRACK); + end.finish(); + + expect(await reader.finished()).toBe(2); + await done(TRACK_ENDED, 2n); + expect((await reader.recvGroup())?.sequence).toBe(0); + expect(await reader.recvGroup()).toBeUndefined(); + expect(reader.final()).toBe(2); +}); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index f6ca858b41..3107cb2b14 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -407,6 +407,61 @@ test("integration: lite draft-06 announce lifecycle", async () => { server.close(); }); +/** Collect announced prefixes until `until` arrives. */ +async function announcedUntil(announced: { next(): Promise<{ prefix: Path.Valid } | undefined> }, until: string) { + const seen: string[] = []; + while (!seen.includes(until)) { + const entry = await withTimeout(announced.next(), 1000, `waiting for ${until}`); + if (!entry) throw new Error("announcements ended"); + seen.push(entry.prefix); + } + return seen; +} + +// A `.`-named broadcast is left out of discovery unless the request opts in or names the +// dot segment. lite-06 cannot carry the opt-in, so its peer never lists the hidden path. +for (const [protocol, carriesOptIn] of [ + [Lite.ALPN_07_WIP, true], + [Lite.ALPN_06, false], + [Ietf.ALPN.DRAFT_19, true], + [Ietf.ALPN.DRAFT_16, true], +] as const) { + test(`integration: ${protocol} hides dot paths from discovery`, async () => { + const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + // Published first, so a reader that may see it lists it before `visible`. + const hidden = publish(origin, Path.from(".x/y")); + const visible = publish(origin, Path.from("visible")); + + const plain = client.announced(); + expect(await announcedUntil(plain, "visible")).toEqual(["visible"]); + + // An IETF reader shares the session's table, so `visible` may land before the + // opted-in request's own answer; wait on the hidden path itself where it is due. + const opted = client.announced(undefined, { hidden: true }); + if (carriesOptIn) await announcedUntil(opted, ".x/y"); + else expect(await announcedUntil(opted, "visible")).toEqual(["visible"]); + + if (carriesOptIn) { + const named = client.announced(Path.Pattern.parse(".x/**")); + expect(await announcedUntil(named, ".x/y")).toEqual([".x/y"]); + named.close(); + } + + plain.close(); + opted.close(); + hidden.close(); + visible.close(); + client.close(); + server.close(); + }); +} + test("integration: lite draft-05 datagram delivery", async () => { const enc = new TextEncoder(); const dec = new TextDecoder(); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 77316e00c8..3f235938cd 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -37,6 +37,15 @@ export function scopeHead(scope: Path.Pattern): Path.Valid { return Path.from(scope.head); } +/** + * Whether a segment of `path` below `prefix` starts with `.`, which hides it from announce + * discovery unless the request opts in. A path at or above the prefix never hides. + */ +export function hiddenBelow(prefix: Path.Valid, path: Path.Valid): boolean { + const below = Path.stripPrefix(prefix, path); + return below !== null && Path.parts(below).some((part) => part.startsWith(".")); +} + /** Whether the announced prefix's subtree overlaps `scope`. */ export function scopeOverlaps(scope: Path.Pattern, prefix: Path.Valid): boolean { return scope.overlaps(Path.Pattern.subtree(prefix)); @@ -97,6 +106,8 @@ export interface TrackRequestOptions { export const hooks: { /** Mint a track {@link Request}; assigned by `track.ts`. */ makeRequest: (options: TrackRequestOptions) => Request; + /** Access the existing producer while a request awaits immutable wire metadata. */ + pendingTrackProducer: (request: Request) => Producer; /** * Take the next group the subscriber's cursor allows, without waiting; assigned by `track.ts`. * @@ -141,6 +152,9 @@ export const hooks: { makeRequest: () => { throw new Error("track.ts not loaded"); }, + pendingTrackProducer: () => { + throw new Error("track.ts not loaded"); + }, tryRecvGroup: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/lite/announce.test.ts b/js/net/src/lite/announce.test.ts index c0efde17c5..4a6acc7dfc 100644 --- a/js/net/src/lite/announce.test.ts +++ b/js/net/src/lite/announce.test.ts @@ -142,6 +142,15 @@ test("AnnounceRequest drops excludeHop on draft-06", async () => { expect(with06.byteLength).toBeLessThan(with05.byteLength); }); +// Draft07 carries the hidden opt-in; every earlier version decodes as not opted in. +test("AnnounceRequest carries hidden from draft-07", async () => { + for (const hidden of [false, true]) { + const msg = new AnnounceRequest(Path.from("room/"), 0n, hidden); + expect((await requestRoundTrip(msg, Version.DRAFT_07)).hidden).toBe(hidden); + expect((await requestRoundTrip(msg, Version.DRAFT_06)).hidden).toBe(false); + } +}); + // The draft reserves Hop ID 0 for a responder that was never assigned an id, or that // withholds it to obscure its routing. Rejecting it tore down the announce stream of a // conforming publisher. diff --git a/js/net/src/lite/announce.ts b/js/net/src/lite/announce.ts index aca95eeaad..d9d6bf7874 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -3,7 +3,7 @@ import { type Cost, type Hop, HopSchema, MAX_HOPS, UNKNOWN_HOP } from "../hop.ts import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; -import { hasAnnounceId, hasAnnounceOk, hasExcludeHop, hasRouteCost, Version } from "./version.ts"; +import { hasAnnounceId, hasAnnounceOk, hasExcludeHop, hasHidden, hasRouteCost, Version } from "./version.ts"; // Pre-lite-06 inner status values, carried inside the single ANNOUNCE_BROADCAST body. const STATUS_ENDED = 0; @@ -266,10 +266,15 @@ export class AnnounceRequest { * * Must be a bigint: peer origins are up to 62 bits and overflow u53. */ excludeHop: bigint; + /** Lite07+: also announce routes with a `.`-prefixed segment below the prefix. Not on + * the wire earlier, so a value set here is ignored when encoding for an older version + * and decodes as false. */ + hidden: boolean; - constructor(prefix: Path.Valid, excludeHop: bigint = 0n) { + constructor(prefix: Path.Valid, excludeHop: bigint = 0n, hidden = false) { this.prefix = prefix; this.excludeHop = excludeHop; + this.hidden = hidden; } async #encode(w: Writer, version: Version) { @@ -277,12 +282,16 @@ export class AnnounceRequest { if (hasExcludeHop(version)) { await w.u62(this.excludeHop); } + if (hasHidden(version)) { + await w.bool(this.hidden); + } } static async #decode(r: Reader, version: Version): Promise { const prefix = Path.decode(await r.string()); const excludeHop = hasExcludeHop(version) ? await r.u62() : 0n; - return new AnnounceRequest(prefix, excludeHop); + const hidden = hasHidden(version) ? await r.bool() : false; + return new AnnounceRequest(prefix, excludeHop, hidden); } async encode(w: Writer, version: Version): Promise { diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 3aca065ab6..75e681d6f8 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -170,8 +170,8 @@ export class Connection implements Established { } } - announced(scope?: Path.Pattern): announce.Consumer { - return this.#subscriber.announced(scope); + announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer { + return this.#subscriber.announced(scope, options); } async #runSession() { diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 2410765e41..30b1f5bbce 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1088,6 +1088,14 @@ test("lite draft-06: a subscription starting mid-group skips the head", async () expect(served).toEqual([{ sequence: 0, frameStart: 2, payloads: ["c", "d"] }]); }); +// A start at the group's final frame count is a valid, empty range: FIN, don't reset. +// A relay resuming a parked track asks for exactly this. +test("lite draft-06: a subscription starting at the end of a group serves it empty", async () => { + const { start, served } = await serve({ 0: ["a", "b"] }, { startGroup: 0, startFrame: 2 }); + expect(start).toBe(0); + expect(served).toEqual([{ sequence: 0, frameStart: 2, payloads: [] }]); +}); + // The end bound is inclusive. test("lite draft-06: a subscription capped mid-group stops at the end frame", async () => { const { served } = await serve( @@ -1317,22 +1325,27 @@ test("lite draft-05: a group waiting for a stream slot is dropped when the subsc // The publisher FINs the subscribe stream itself once a track ends, which must not be // mistaken for the subscriber leaving: SUBSCRIBE_END counts those queued groups as -// delivered, so dropping them here would strand the tail of every finite track. +// delivered, so dropping them here would strand the tail of every finite track. The FIN +// tells the subscriber every group is accounted for, so it waits for the queued group. test("lite draft-05: a group waiting for a stream slot survives the track finishing", async () => { const { client, track, freeSlot, outcome, close } = await saturatedGroup(); track.close(); - // Read to the FIN the publisher sends after SUBSCRIBE_END. That FIN is the moment a - // cancel keyed on our own close would fire, so the slot must not free up before it. + // SUBSCRIBE_END goes out while the group is still waiting for its slot. for (;;) { const resp = await decodeSubscribeResponse(client.reader, Version.DRAFT_05); if ("end" in resp) break; } - await client.reader.closed; + + // The FIN holds until the queued group is on the wire. + const fin = client.reader.closed.then(() => "fin" as const); + const idle = new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)); + expect(await Promise.race([fin, idle])).toBe("pending"); freeSlot(); expect(await outcome).toBe("sent"); + expect(await fin).toBe("fin"); close(); }); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index bb2230ed82..b9fb8dc098 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1,9 +1,9 @@ -import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import type * as broadcast from "../broadcast.ts"; import { error, NotFound, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; -import { hooks } from "../internal.ts"; +import { hiddenBelow, hooks } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Reader, type Stream, Writer } from "../stream.ts"; @@ -32,7 +32,11 @@ import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, resolvesStart, // Where each originated route lands under the requested prefix: its suffix beneath // the prefix, or the empty suffix for a route above it, where the most specific // such route wins the way a request through the prefix would resolve. -function presented(prefix: Path.Valid, table: ReadonlyMap): Map { +function presented( + prefix: Path.Valid, + table: ReadonlyMap, + hidden: boolean, +): Map { const out = new Map(); let rootLen = -1; for (const [covered, snap] of table) { @@ -42,6 +46,8 @@ function presented(prefix: Path.Valid, table: ReadonlyMap