diff --git a/.claude/rules/prompting.md b/.claude/rules/prompting.md index 750a9e06cb..e373e9495f 100644 --- a/.claude/rules/prompting.md +++ b/.claude/rules/prompting.md @@ -1,6 +1,5 @@ --- paths: - - "**/CLAUDE.md" - "**/AGENTS.md" - "CONTRIBUTING.md" - "PROMPTING.md" diff --git a/.claude/skills/plan-quests/SKILL.md b/.claude/skills/plan-quests/SKILL.md index 0f55d0a72f..8ba5c9ff8e 100644 --- a/.claude/skills/plan-quests/SKILL.md +++ b/.claude/skills/plan-quests/SKILL.md @@ -3,7 +3,7 @@ name: plan-quests description: Scope, create, and publish a quest through an interactive grilling interview. Use when the user invokes /plan-quests, asks to plan a quest, or wants unsettled work split into quests. --- -Before you begin, read `quest/CLAUDE.md` completely. +Before you begin, read `quest/AGENTS.md` completely. Interview the user until you reach a shared understanding. diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md index d8782dd12b..b8e0f44ffa 100644 --- a/.claude/skills/spawn-quests/SKILL.md +++ b/.claude/skills/spawn-quests/SKILL.md @@ -3,7 +3,7 @@ name: spawn-quests description: Spawn background agents work on quests in parallel. --- -Before you begin, read `quest/CLAUDE.md` completely. +Before you begin, read `quest/AGENTS.md` completely. Your goal is to execute, plan, and/or merge quests in parallel. If you are unsure of the best course of action, ask the user for clarification before proceeding. diff --git a/.claude/skills/start-quest/SKILL.md b/.claude/skills/start-quest/SKILL.md index 4ec47379e5..73d0911e40 100644 --- a/.claude/skills/start-quest/SKILL.md +++ b/.claude/skills/start-quest/SKILL.md @@ -3,7 +3,7 @@ name: start-quest description: Start work on a quest. --- -Before you begin, read `quest/CLAUDE.md` completely. +Before you begin, read `quest/AGENTS.md` completely. 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. @@ -11,7 +11,7 @@ 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. +Claim it as `quest/AGENTS.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. Keep scratch files (PR body, logs, notes) in the worktree's gitignored `.scratch/`. diff --git a/.config/nextest.toml b/.config/nextest.toml index 1d52e0c2ef..a79c667e71 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -16,7 +16,7 @@ slow-timeout = { period = "30s", terminate-after = 2 } # Retries hide flakes, and this repo treats an intermittent failure as a real -# race until proven otherwise (see Root Cause First in CLAUDE.md). +# race until proven otherwise (see Root Cause First in AGENTS.md). retries = 0 # `--all-targets` still compiles the criterion benches, which is what catches a diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index a64a9febb6..8fe97dd4f4 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -88,6 +88,10 @@ jobs: run: nix develop --command just test max-age shell: bash -leo pipefail {0} + - name: Subscription termination interop + run: nix develop --command just test bare-fin + shell: bash -leo pipefail {0} + - name: Interop (full matrix) run: nix develop --command just test interop --all shell: bash -leo pipefail {0} diff --git a/.opencode/agent/review.md b/.opencode/agent/review.md index 4f9702b65d..785460f021 100644 --- a/.opencode/agent/review.md +++ b/.opencode/agent/review.md @@ -38,7 +38,7 @@ it like a prompt: terse, specific, and nothing the reader has to re-derive. ## Before reviewing Read `AGENTS.md`, `CONTRIBUTING.md`, and `PROMPTING.md` at the repo root, then -the nested `CLAUDE.md` beside any touched code. They hold the rules you enforce +the nested `AGENTS.md` beside any touched code. They hold the rules you enforce and how to write for an agent reader; cite them by file and heading. Confirm the base with `gh pr view --json baseRefName`, then diff diff --git a/AGENTS.md b/AGENTS.md deleted file mode 120000 index 681311eb9c..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..494dbdaa8f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +MoQ (Media over QUIC) is a live media delivery protocol providing real-time latency at massive scale. +This is a polyglot monorepo with Rust (server/native) and TypeScript (browser) implementations. + +# Context + +This file is split into nested `AGENTS.md` files based on the language/situation. + +- Read `CONTRIBUTING.md` when dealing with PRs. +- Read `PROMPTING.md` before instructing other agents via context, memory, plans, or reviews. +- Read `*/AGENTS.md` if the file exists before touching a root directory. +- Read (and keep up-to-date) `doc/*.md` for user-facing documentation. + +# Required + +- Pull the latest origin changes before working. +- Dig into the root cause and fix it at the source. Never work around a fixable bug with a retry, sleep, or timeout. +- Fail loud and early. Error on unsupported or malformed input rather than warn and continue: supported or refused. +- Reproduce bugs before fixing them. Land each fix with a regression test that fails without it, when one is easy. +- Keep the PR focused. No unrelated refactors, formatting churn, or drive-by changes; split when in doubt. +- Refactor aggressively for long-term maintainability, but re-evaluate the direction as you learn. +- Propose a course change, even suggest abandoning a PR, rather than finish a half-solution. +- When a decision is the maintainer's (API shape, naming, scope), ask with 2-3 options and a recommendation. +- All tests need to be wired into CI, at least a nightly. +- Never edit an `AGENTS.md`, `CONTRIBUTING.md`, or skill without being prompted, and read `PROMPTING.md` first. +- No em dashes. +- Match the existing conventions, patterns, and naming when possible. +- Fix any outdated docs and comments inline; don't add a separate PR for it. +- Add the AI marker `(Written by )` to any posts on GitHub, excluding commit messages that contain `Co-Authored-By:` trailers. +- Any AI comments may be challenged, and not confused with human maintainers. +- Prompt the user to decide when unsure, but always provide recommendations. + +# Guidelines + +- Prefer a maintained crate over hand-rolling non-core functionality. +- Anything that fans out (N publishers, M subscribers, routes, sessions) gets a benchmark swept over both axes, so a cost that grows with the table instead of the touched path shows up as a slope. +- New dependencies should use the newest, stable version. +- Do not bump package versions unless asked. Releases are cut separately. +- Comments should explain the non-obvious why, and never the history. +- Inline simple helpers. +- Question whether functionality is needed at all before adding it. +- Deleting code is better than adding code. +- Suggest follow up sessions and quests when finished, interactively prompting the user. +- Prefer the simple solution. +- Say it once, in the fewest words that hold up. +- Critically think, and decide if the complexity is worth it. Simple is better. +- Prefer a quest over a GitHub issue for work needing durable scope. +- Use interactive prompts when possible. +- 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. +- 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 + +The API is the most important thing to get right. +A bad shape costs a breaking change in every language, and the surface is huge. + +- Report the public API and wire impact of every change, in the PR description and whenever asked. +- Keep things private until a consumer needs them. Scrutinize every new exported item. +- Never add `foo_with_x`, `foo_checked`, or a compatibility shim. Make the breaking change to `foo` on `dev` instead. Additive changes stay on `main`. +- Let the type system make misuse unrepresentable +- Avoid callback parameters. Return a handle, an event, or a Producer/Consumer split. +- Avoid 4+ args; use a struct or object. +- Name by role, not today's implementation. +- When a name or shape feels awkward, propose alternatives with a recommendation instead of shipping it. +- Short names under a module namespace (`encode::Config`, not `EncoderConfig`). Mirror names across Rust, JS, and the bindings. +- Document every exported symbol in one plain line, the way you'd say it out loud. +- Prefer refactoring and simplification when working on the `dev` branch; many APIs have have not been published yet. + +MoQ is split into many layers as part of the public API. +The boundary between packages is extremely important to keep things modular and reusable. + +# Development + +PRs target `main`. +`dev` is reserved for semver-breaking API changes, except for `0.0.x` and unpublished/private packages. +Wire changes should be backwards compatible for any *published* drafts/versions. + +Before starting, `git fetch origin` and set the upstream to the base branch. +If a published API break requires `dev`, retarget the PR to `dev`, set the upstream to `origin/dev`, then rebase onto it. + +Use the Nix dev shell so tooling matches CI. +direnv loads it automatically, but if not: `nix develop --command ...`. + +```bash +just check # Lint, compile, and test what the branch changed +just fix # Auto-fix lint/formatting, same scope +``` + +These diff the branch against its base and only run the affected packages. + +# Cross-Package Sync + +| Change in | Also update | +|---|---| +| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `doc/lib/{py,swift,kt,go,dart,c}` | +| `rs/moq-net` wire/API | `js/net`, `doc/concept`, `drafts/draft-lcurley-moq-lite.md` (if the wire spec changes) | +| `rs/hang` catalog/container | `js/hang`, `doc/concept`, `drafts/draft-lcurley-moq-hang.md` (if the format spec changes) | +| `rs/moq-token` | `js/token` | +| `rs/moq-stats` wire (track names, frame shapes) | `doc/bin/relay/config.md` (stats section) | +| `rs/moq-relay` config/behavior | `doc/bin/relay/` | +| `rs/moq-cli` | `doc/bin/cli.md` | +| `rs/moq-token-cli` | `doc/bin/relay/auth.md`, `doc/lib/rs/moq-token.md`, `doc/lib/rs/index.md` | +| `rs/moq-gst` | `doc/bin/gstreamer.md` | +| `rs/libmoq` C ABI (`moq.h`) | `cpp/obs/src`, `doc/bin/obs.md` | +| `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | +| a kramdown-rfc construct new to `drafts/` | `doc/.vitepress/drafts.ts`, which translates the drafts into `/draft/` site pages | + +Any wire-format change updates its matching IETF draft in the same PR, including framing, message fields, enum values, and version negotiation. Use the feature-specific draft for extensions and validate with `just drafts check`. See `drafts/AGENTS.md`. + +For wire, `moq-ffi`, or gateway changes, also run `just test interop --all` for cross-language interop; plain `interop` is Rust-only. + +When a CLI interface changes, search the whole repo for the binary name and update every example invocation, including docs and demo recipes. Check examples against `--help`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ba39b50d64..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,113 +0,0 @@ -MoQ (Media over QUIC) is a live media delivery protocol providing real-time latency at massive scale. -This is a polyglot monorepo with Rust (server/native) and TypeScript (browser) implementations. - -# Context - -This file is split into nested `CLAUDE.md` files based on the language/situation. - -- Read `CONTRIBUTING.md` when dealing with PRs. -- Read `PROMPTING.md` before instructing other agents via context, memory, plans, or reviews. -- Read `*/CLAUDE.md` if the file exists before touching a root directory. -- Read (and keep up-to-date) `doc/*.md` for user-facing documentation. - -# Required - -- Pull the latest origin changes before working. -- Dig into the root cause and fix it at the source. Never work around a fixable bug with a retry, sleep, or timeout. -- Fail loud and early. Error on unsupported or malformed input rather than warn and continue: supported or refused. -- Reproduce bugs before fixing them. Land each fix with a regression test that fails without it, when one is easy. -- Keep the PR focused. No unrelated refactors, formatting churn, or drive-by changes; split when in doubt. -- Refactor aggressively for long-term maintainability, but re-evaluate the direction as you learn. -- Propose a course change, even suggest abandoning a PR, rather than finish a half-solution. -- When a decision is the maintainer's (API shape, naming, scope), ask with 2-3 options and a recommendation. -- All tests need to be wired into CI, at least a nightly. -- Never edit a `CLAUDE.md`, `CONTRIBUTING.md`, or skill without being prompted, and read `PROMPTING.md` first. -- No em dashes. -- Match the existing conventions, patterns, and naming when possible. -- Fix any outdated docs and comments inline; don't add a separate PR for it. -- Add the AI marker `(Written by )` to any posts on GitHub, excluding commit messages that contain `Co-Authored-By:` trailers. -- Any AI comments may be challenged, and not confused with human maintainers. -- Prompt the user to decide when unsure, but always provide recommendations. - -# Guidelines - -- Prefer a maintained crate over hand-rolling non-core functionality. -- Anything that fans out (N publishers, M subscribers, routes, sessions) gets a benchmark swept over both axes, so a cost that grows with the table instead of the touched path shows up as a slope. -- New dependencies should use the newest, stable version. -- Do not bump package versions unless asked. Releases are cut separately. -- Comments should explain the non-obvious why, and never the history. -- Inline simple helpers. -- Question whether functionality is needed at all before adding it. -- Deleting code is better than adding code. -- Suggest follow up sessions and quests when finished, interactively prompting the user. -- Prefer the simple solution. -- Say it once, in the fewest words that hold up. -- Critically think, and decide if the complexity is worth it. Simple is better. -- Prefer a quest over a GitHub issue for work needing durable scope. -- Use interactive prompts when possible. -- 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. -- 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 - -The API is the most important thing to get right. -A bad shape costs a breaking change in every language, and the surface is huge. - -- Report the public API and wire impact of every change, in the PR description and whenever asked. -- Keep things private until a consumer needs them. Scrutinize every new exported item. -- Never add `foo_with_x`, `foo_checked`, or a compatibility shim. Make the breaking change to `foo` on `dev` instead. Additive changes stay on `main`. -- Let the type system make misuse unrepresentable -- Avoid callback parameters. Return a handle, an event, or a Producer/Consumer split. -- Avoid 4+ args; use a struct or object. -- Name by role, not today's implementation. -- When a name or shape feels awkward, propose alternatives with a recommendation instead of shipping it. -- Short names under a module namespace (`encode::Config`, not `EncoderConfig`). Mirror names across Rust, JS, and the bindings. -- Document every exported symbol in one plain line, the way you'd say it out loud. -- Prefer refactoring and simplification when working on the `dev` branch; many APIs have have not been published yet. - -MoQ is split into many layers as part of the public API. -The boundary between packages is extremely important to keep things modular and reusable. - -# Development - -PRs target `main`. -`dev` is reserved for semver-breaking API changes, except for `0.0.x` and unpublished/private packages. -Wire changes should be backwards compatible for any *published* drafts/versions. - -Before starting, `git fetch origin` and set the upstream to the base branch. -If a published API break requires `dev`, retarget the PR to `dev`, set the upstream to `origin/dev`, then rebase onto it. - -Use the Nix dev shell so tooling matches CI. -direnv loads it automatically, but if not: `nix develop --command ...`. - -```bash -just check # Lint, compile, and test what the branch changed -just fix # Auto-fix lint/formatting, same scope -``` - -These diff the branch against its base and only run the affected packages. - -# Cross-Package Sync - -| Change in | Also update | -|---|---| -| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `doc/lib/{py,swift,kt,go,dart,c}` | -| `rs/moq-net` wire/API | `js/net`, `doc/concept`, `drafts/draft-lcurley-moq-lite.md` (if the wire spec changes) | -| `rs/hang` catalog/container | `js/hang`, `doc/concept`, `drafts/draft-lcurley-moq-hang.md` (if the format spec changes) | -| `rs/moq-token` | `js/token` | -| `rs/moq-stats` wire (track names, frame shapes) | `doc/bin/relay/config.md` (stats section) | -| `rs/moq-relay` config/behavior | `doc/bin/relay/` | -| `rs/moq-cli` | `doc/bin/cli.md` | -| `rs/moq-token-cli` | `doc/bin/relay/auth.md`, `doc/lib/rs/moq-token.md`, `doc/lib/rs/index.md` | -| `rs/moq-gst` | `doc/bin/gstreamer.md` | -| `rs/libmoq` C ABI (`moq.h`) | `cpp/obs/src`, `doc/bin/obs.md` | -| `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | -| a kramdown-rfc construct new to `drafts/` | `doc/.vitepress/drafts.ts`, which translates the drafts into `/draft/` site pages | - -Any wire-format change updates its matching IETF draft in the same PR, including framing, message fields, enum values, and version negotiation. Use the feature-specific draft for extensions and validate with `just drafts check`. See `drafts/CLAUDE.md`. - -For wire, `moq-ffi`, or gateway changes, also run `just test interop --all` for cross-language interop; plain `interop` is Rust-only. - -When a CLI interface changes, search the whole repo for the binary name and update every example invocation, including docs and demo recipes. Check examples against `--help`. diff --git a/PROMPTING.md b/PROMPTING.md index 5d886bc253..36f7956fbb 100644 --- a/PROMPTING.md +++ b/PROMPTING.md @@ -18,13 +18,13 @@ Mocking up an API makes a lot of sense but again, it should be flexible enough t # Context -This includes editing any `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, etc. +This includes editing any `AGENTS.md`, `CONTRIBUTING.md`, etc. These files need to be light because they're loaded into every prompt. More specific context (ex. per language or project) should exist in optional files/folders. These base prompts are meant to fix regular agent mistakes. -If there is a history of bad practices, or sub-optimal behavior, update `CLAUDE.md` to steer away from them. +If there is a history of bad practices, or sub-optimal behavior, update `AGENTS.md` to steer away from them. If you think a slight tweak to the base prompt would help, propose it. Focus on best practices and conventions. diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 99b658406f..1c1f59ed10 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -200,11 +200,9 @@ _includes: (cd ../.. && cargo check --locked --quiet -p libmoq) - # Ask cargo where its build script ran rather than reconstructing the path. - # CARGO_TARGET_DIR, a `--target` triple and a custom profile each move the - # generated header, and guessing wrong doesn't fail: it type-checks against - # whatever stale copy the default directory still holds. Replays the check - # above from cache, so it costs a process rather than a compile. + # rs/libmoq/build.rs writes the header into its OUT_DIR, so ask cargo where + # that is. Replays the check above from cache, so it costs a process rather + # than a compile. out_dir=$(cd ../.. && cargo check --locked -p libmoq --message-format=json | jq -r 'select(.reason == "build-script-executed") | select(.package_id | tostring | test("libmoq")) | .out_dir' | tail -1) @@ -212,9 +210,7 @@ _includes: echo "cargo reported no build script output for libmoq" >&2 exit 1 fi - # //build/libmoq-/out, and the header sits beside the - # profile directory. rs/libmoq/build.rs derives it from OUT_DIR the same way. - moq_include="$(dirname "$(dirname "$(dirname "$(dirname "$out_dir")")")")/include" + moq_include="$out_dir/include" if [ ! -f "$moq_include/moq.h" ]; then echo "$moq_include/moq.h missing after 'cargo check --locked -p libmoq'" >&2 exit 1 diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index bbe39e6879..7a5e071951 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -301,10 +301,10 @@ void MoQOutput::Reset() } audio_tracks.clear(); - // Finish the broadcast so the origin unpublishes it immediately; Start() + // Close the broadcast so the origin retracts it immediately; Start() // creates a fresh one on restart. if (broadcast > 0) { - moq_publish_finish(broadcast); + moq_publish_close(broadcast); broadcast = 0; } } diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index e28039ea60..c20c9d30c8 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -233,7 +233,7 @@ int32_t moq_publish_announce(uint32_t, const moq_route *) return 0; } -int32_t moq_publish_finish(uint32_t) +int32_t moq_publish_close(uint32_t) { return 0; } diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 8727301bc0..fa998168f9 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -85,7 +85,7 @@ typedef GroupProducer = MoqGroupProducer; /// The read side of a single group: yields timestamped raw frames. typedef GroupConsumer = MoqGroupConsumer; -/// The write side of a media track fed pre-framed payloads. +/// The write side of a media track; discontinuity() marks a break between pre-framed payloads. typedef MediaProducer = MoqMediaProducer; /// The write side of a media track fed a raw byte stream, with frame boundaries inferred. diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 8fcc824ab2..278456ee19 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -102,7 +102,8 @@ void main() { client.close(); serverSession.cancel(code: 0); track.finish(); - broadcast.finish(); + broadcast.close(); + broadcast.close(); // a second close is a no-op server.close(); }); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 4ada2c2a1c..ef1383523e 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -6125,6 +6123,7 @@ abstract class MoqBroadcastProducerInterface { required MoqJsonStreamConfig config, }); void announce({required MoqRoute route}); + void close(); MoqBroadcastConsumer consume(); MoqBroadcastDynamic dynamic_(); void finish(); @@ -6266,6 +6265,15 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { }, moqExceptionErrorHandler); } + void close() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbroadcastproducer_close( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + MoqBroadcastConsumer consume() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqbroadcastproducer_consume( @@ -6871,6 +6879,7 @@ class FfiConverterMoqGroupRequest { abstract class MoqMediaProducerInterface { void cut(); MoqTrackDemand demand(); + void discontinuity(); void finish(); void flush({required int timestampUs}); String name(); @@ -6923,6 +6932,15 @@ class MoqMediaProducer implements MoqMediaProducerInterface { ); } + void discontinuity() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqmediaproducer_discontinuity( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + void finish() { return rustCall((status) { uniffi_moq_ffi_fn_method_moqmediaproducer_finish( @@ -10530,6 +10548,14 @@ external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_announce( Pointer uniffiStatus, ); +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_close( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10912,6 +10938,14 @@ external Pointer uniffi_moq_ffi_fn_method_moqmediaproducer_demand( Pointer uniffiStatus, ); +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqmediaproducer_discontinuity( + Pointer ptr, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -12202,6 +12236,9 @@ uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_close(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume(); @@ -12309,6 +12346,9 @@ external int uniffi_moq_ffi_checksum_method_moqmediaproducer_cut(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqmediaproducer_demand(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqmediaproducer_discontinuity(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqmediaproducer_finish(); @@ -12727,7 +12767,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_available() != - 42497) { + 37458) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_cancel() != 63175) { @@ -12799,13 +12839,16 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_close() != 19191) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume() != 27634) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_dynamic() != 55635) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_finish() != 7183) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_finish() != 29562) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio() != @@ -12909,6 +12952,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqmediaproducer_demand() != 44491) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqmediaproducer_discontinuity() != + 37570) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqmediaproducer_finish() != 38480) { 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 ab87a64ed3..1eefdf6293 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -8,6 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; class UniffiInternalError implements Exception { @@ -247,6 +248,7 @@ mixin FfiConverterPrimitive on FfiConverter { @override T lower(T value) => value; } + Uint8List createUint8ListFromInt(int value) { int length = value.bitLength ~/ 8 + 1; if (length != 4 && length != 8) { diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index c3695b214d..fa2cc877e5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -83,6 +83,11 @@ 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. +After a pause, flush, or changed TIME segment, the next media buffer starts a new +timeline epoch and resets the handoff baseline. The pause does not inflate +advertised jitter, and previously measured maxima remain. Resumed timestamps +must continue forward on the broadcast media clock. + ## moqsrc Pads are named by kind and appear as the catalog announces renditions: diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 7de0e24bc8..81f7773f9b 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -67,7 +67,10 @@ can be neither discovered nor requested. A broadcast published locally competes with remote routes to its path on cost like any other route, winning only a tie. Retracting a route (an unannounce, or the peer's `ANNOUNCE_END`) 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. +in flight alone: each track runs to its own end or failure. On moq-lite 05 and +newer, a clean end requires `SUBSCRIBE_END` before the publisher's FIN. A FIN +without that declaration fails the subscription with `ProtocolViolation`; older +moq-lite versions use FIN alone. moq-transport requires `PUBLISH_DONE` before FIN. moq-transport sessions behave the same when a namespace is withdrawn. ### Hidden broadcasts @@ -136,7 +139,7 @@ root. The pattern scope filters which prefixes are visible without changing a route's prefix. Announce events carry the covered path, captures, and what happened to it: Rust `announce::Event::{Announced, Updated, Retracted}`, each holding an `announce::Announce { prefix, captures: Option>, route }`, -and TypeScript `Announce.Update { path, captures, route, kind }`, where the kind +and TypeScript `Announce.Update { prefix, captures, route, kind }`, where the kind is announced, updated (a reprice in place), or retracted. Captures are present when the announced prefix pins every wildcard in the most-specific matching scope member. The Rust consumer is a `Stream` and the TypeScript one an async @@ -150,7 +153,13 @@ Announcements are hints; requests are the authority. When a subscriber asks for a covered path the advertiser will not serve, the advertiser refuses that request rather than narrowing the claim, and no message narrows a route. Token scope is any pattern union; the session asks for each member's literal head on -the prefix-only wire and filters locally. +the prefix-only wire and filters locally. In Rust and TypeScript, +`origin.scope(root, patterns)` narrows the handle's permissions and presents paths +relative to `root`. Nested scopes intersect with their parent. A session receiving +into that scoped origin asks for the literal heads of its allowed patterns, +coalescing duplicate or nested heads. An unscoped origin still asks for the empty +prefix, covering every namespace. These subscriptions include hidden routes; +each local announcement reader decides whether to show them. ```typescript import { Path } from "@moq/net"; diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index e518b06f5c..6cac01a951 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -25,8 +25,12 @@ export PKG_CONFIG_PATH="moq-$ver-$target/lib/pkgconfig" cc app.c $(pkg-config --cflags --libs --static moq) -o app ``` -From source: `cargo build --release -p libmoq` writes `target/release/libmoq.a` -and `target/include/moq.h`. +From source, `add_subdirectory(rs/libmoq)` in CMake gives a `moq` target to +link. A bare `cargo build --release -p libmoq` writes `target/release/libmoq.a`, +and `moq.h` plus `moq.pc` land in the build script's `OUT_DIR` under `include/` +and `lib/pkgconfig/`, a hashed path that `--message-format=json` reports as +`out_dir`. That `moq.pc` expects the install layout, with `libmoq.a` in `lib/` +beside `pkgconfig/`. ## Shape of the API @@ -39,11 +43,11 @@ and `target/include/moq.h`. - **Raw decode output.** `moq_video_decoder_output` selects the decoded CPU pixel format (`MOQ_VIDEO_PIXEL_FORMAT_I420` or `_RGBA`) and target size (`width`/`height`, both zero for native; otherwise even and non-zero). Unknown formats and invalid sizes fail `moq_decode_video` before subscribing. - **Decoded frames.** Each frame id owns its decoded picture until `moq_decode_video_frame_free`, including after `moq_decode_video_cancel` and the terminal callback. The first `moq_decode_video_frame` call converts it to the requested layout and size; its `data` pointer then stays put until the id is freed. A conversion failure returns a negative code for that frame only. Free frames promptly: held frames hold decoder buffers, and a decoder whose buffers are all held stalls. - **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. +- **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`, `failover_delay_us`/`resolution_delay_us` for Happy Eyeballs, `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, 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 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 `moq_announce_config` (NULL or zeroed for everything): 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); set `hidden` or name the dot segment in `prefix` to list them. +- **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), `moq_publish_close` (ends the broadcast for good and releases its handle; `moq_publish_finish` is its deprecated alias), 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 `moq_announce_config` (NULL or zeroed for everything): 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); set `hidden` or name the dot segment in `prefix` to list them. ```c moq_client_config config; @@ -60,6 +64,8 @@ if (session < 0) 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. +Call `moq_publish_media_discontinuity(media)` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + ## 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 37f7d5c6a9..ff1a77178f 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -65,7 +65,8 @@ await for (final request in server.requests()) { 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 -advertisement; `origin.dynamic_(prefix:, route:)` claims `prefix` and +advertisement, and `broadcast.close()` ends the broadcast for good (a second +call is a no-op; `finish()` is its deprecated alias); `origin.dynamic_(prefix:, route:)` claims `prefix` and every path beneath it (`''` for everything; Dart spells the origin method `dynamic_` because `dynamic` is reserved). Hold the returned handle while the claim should stay advertised, and reject the requests you will not serve. A @@ -109,6 +110,8 @@ catalog and container types are there, so already-encoded frames flow through `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. +Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + ## 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 eb6eecbc0e..227a50f989 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -66,15 +66,18 @@ video, _ := broadcast.EncodeVideo( ) _ = video.Write(moq.VideoFrame{TimestampUs: pts, Data: rgba}) _ = broadcast.Announce(moq.Route{}) -broadcast.Finish() // keep the producer reachable while publishing, then finish explicitly +broadcast.Close() // keep the producer reachable while publishing, then close 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. +Call `media.Discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + 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 -advertisement; `origin.Dynamic(prefix, route)` claims `prefix` and every +advertisement, and `broadcast.Close()` ends the broadcast for good (a second +call is a no-op; `Finish` is its deprecated alias); `origin.Dynamic(prefix, route)` claims `prefix` and every 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. `Announced(options)` combines diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index a9f208e93e..013a2d8489 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -53,10 +53,13 @@ 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. +Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + 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 -advertisement; `origin.dynamic(prefix, route)` claims `prefix` and every +advertisement, and `broadcast.close()` (or `use { }`) releases the producer, +ending the broadcast once no `dynamic()` handle remains; `origin.dynamic(prefix, route)` claims `prefix` and every 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 diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 6aa0efaba4..3a562d03a7 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -71,10 +71,14 @@ 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. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + 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)` / -`broadcast.unannounce()` own that exact-path advertisement; +`broadcast.unannounce()` own that exact-path advertisement, and +`broadcast.close()` ends the broadcast for good (a second call is a no-op; +`finish()` is its deprecated alias); `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned handle while the claim should stay advertised, and reject the requests you will not serve. A route is a diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 7be69723be..be641e9619 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -43,9 +43,12 @@ 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, and delay is how far that minimum trails the earliest track on the same catalog. Both are published as -soon as they rise. Generic imports remain clock-free. Invalid or decreasing -jitter or delay is rejected before the edit is retained, including while the -initial catalog is reserved. +soon as they rise. `import::Track::discontinuity()` marks a source seek or +pause, clears partial input, and restarts the flush baseline without lowering +advertised values. It forwards the container timeline marker, so resumed +timestamps must continue forward on the broadcast clock. Generic imports remain +clock-free. Invalid or decreasing jitter or delay 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. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index e105c44b63..e76c0e236b 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -114,8 +114,12 @@ Three operations, on an origin: - `broadcast.announce(route)` / `broadcast.unannounce()` own that advertisement. Announcing again re-prices the standing route, which competes on cost with remote routes at the same path (a tie goes to the local - broadcast). The route retracts on `unannounce()`, `finish()`, or the last + broadcast). The route retracts on `unannounce()`, `close()`, or the last producer dropping; tracks already in flight carry on to their own end. +- `broadcast.close()` ends the broadcast for good: it retracts, leaves local + discovery, and answers every later track lookup with `Unroutable`. Tracks + already subscribed carry on to their own end. It can never be announced + again. Dropping the last producer does the same. - `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` claims everything). Hold the returned `origin::Dynamic` while the claim should stay advertised; drop it to retract. A request beneath it with diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 4b4b64bffe..6867e3347d 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -57,9 +57,13 @@ 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. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + 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; +`broadcast.unannounce()` own that exact-path advertisement, and +`broadcast.close()` ends the broadcast for good (a second call is a no-op; +`finish()` is its deprecated alias); `session.publish.dynamic(prefix:route:)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not serve. A diff --git a/drafts/CLAUDE.md b/drafts/AGENTS.md similarity index 100% rename from drafts/CLAUDE.md rename to drafts/AGENTS.md diff --git a/go/wrapper/example_test.go b/go/wrapper/example_test.go index 84eef29901..60c3c40d29 100644 --- a/go/wrapper/example_test.go +++ b/go/wrapper/example_test.go @@ -53,8 +53,8 @@ func ExampleClient_CreateBroadcast() { if err != nil { log.Fatal(err) } - // Finishing unpublishes the broadcast immediately. - defer broadcast.Finish() + // Closing ends the broadcast for good. + defer broadcast.Close() media, err := broadcast.PublishAudio(moq.AudioFormatOpus, opusHead()) if err != nil { @@ -91,7 +91,7 @@ func ExampleBroadcastProducer_PublishVideo_videoHint() { if err != nil { log.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() media, err := broadcast.PublishVideo(moq.VideoFormatAvc3, nil, moq.WithVideoHint(moq.VideoHint{})) if err != nil { diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index ea33fd4cec..e16a233d4e 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -115,7 +115,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { if err := track.Finish(); err != nil { t.Fatal(err) } - if err := served.Finish(); err != nil { + if err := served.Close(); err != nil { t.Fatal(err) } } @@ -135,11 +135,27 @@ func TestPublishAudioLifecycle(t *testing.T) { if err := media.Finish(); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } +func TestBroadcastCloseTwiceIsNoop(t *testing.T) { + broadcast, err := moq.NewBroadcastProducer() + if err != nil { + t.Fatal(err) + } + if err := broadcast.Close(); err != nil { + t.Fatal(err) + } + if err := broadcast.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + if _, err := broadcast.PublishTrack("events", nil); err == nil { + t.Fatal("PublishTrack after Close succeeded") + } +} + func TestEncodeAudioWithOpusObject(t *testing.T) { // The producer retains the codec, so releasing the codec and the // config in either order must still encode. @@ -179,7 +195,7 @@ func TestEncodeAudioWithOpusObject(t *testing.T) { if err := producer.Finish(); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -221,7 +237,7 @@ func TestEncodeAudioFrameDurations(t *testing.T) { t.Fatalf("err = %v, want ErrAudio: 2 ms is not an opus frame duration", err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -235,7 +251,7 @@ func TestVideoPropertiesUseDefaultedFields(t *testing.T) { if err := broadcast.SetVideoProperties(moq.VideoProperties{Rotation: &rotation}); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -339,7 +355,7 @@ func TestDecodeVideoFrame(t *testing.T) { if err := video.Finish(); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -795,7 +811,7 @@ func TestDynamicTrackRequest(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -871,7 +887,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -963,7 +979,7 @@ func TestRecvGroupCancelRace(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() var wg sync.WaitGroup for i := 0; i < 16; i++ { @@ -996,7 +1012,7 @@ func TestConsumerCancelConcurrent(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() track, err := broadcast.PublishTrack("x", nil) if err != nil { @@ -1064,7 +1080,7 @@ func TestRequestBroadcastCancelKeepsTheOrigin(t *testing.T) { if err != nil { t.Fatal(err) } - defer served.Finish() + defer served.Close() resolved := make(chan error, 1) go func() { @@ -1097,7 +1113,7 @@ func TestSubscribeTrackCancelKeepsTheBroadcast(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -1166,7 +1182,7 @@ func TestUsedCancelKeepsTheTrack(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() track, err := broadcast.PublishTrack("status", nil) if err != nil { @@ -1213,7 +1229,7 @@ func TestCancelDoesNotLeakGoroutines(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -1327,7 +1343,7 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { if _, err := consumer.RequestBroadcast(ctx, "live"); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index 67eb77e040..78642147c1 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -49,9 +49,8 @@ func (o *OriginProducer) Dynamic(prefix string, route Route) (*OriginDynamic, er // // The broadcast is invisible and unroutable, for this origin's consumers and // peers alike, until [BroadcastProducer.Announce]. Announce it after -// populating tracks. Finish unpublishes immediately, while dropping the -// producer without finishing also unpublishes but reads to subscribers as a -// failure rather than a deliberate end. +// populating tracks. [BroadcastProducer.Close] ends it for good; dropping the +// last handle does the same. func (o *OriginProducer) CreateBroadcast(path string) (*BroadcastProducer, error) { inner, err := o.inner.CreateBroadcast(path) if err != nil { diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 6eccc6d8f6..5b668b3e43 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -262,9 +262,17 @@ func (b *BroadcastProducer) RemoveCatalogSection(name string) error { return b.inner.RemoveCatalogSection(name) } -// Finish closes the broadcast. +// Close ends the broadcast for good: it retracts and serves no new tracks. +// Tracks already subscribed carry on to their own end. Closing again is a no-op. +func (b *BroadcastProducer) Close() error { + return b.inner.Close() +} + +// Finish ends the broadcast. +// +// Deprecated: use [BroadcastProducer.Close]; a broadcast end carries no cause. func (b *BroadcastProducer) Finish() error { - return b.inner.Finish() + return b.inner.Close() } // BroadcastDynamic is a stream of subscriber-requested tracks. @@ -365,6 +373,11 @@ func (m *MediaProducer) Flush(timestampUs uint64) error { return m.inner.Flush(timestampUs) } +// Discontinuity marks a timeline break and restarts handoff measurement, preserving advertised jitter. +func (m *MediaProducer) Discontinuity() error { + return m.inner.Discontinuity() +} + // Cut draws a group boundary here. // // Audio has no boundary of its own (every packet is independently decodable), so this is diff --git a/go/wrapper/reconnect_test.go b/go/wrapper/reconnect_test.go index 72e71536d2..762a46c38d 100644 --- a/go/wrapper/reconnect_test.go +++ b/go/wrapper/reconnect_test.go @@ -166,7 +166,7 @@ func TestReconnectAcrossRelayRestart(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() track, err := broadcast.PublishTrack("data", nil) if err != nil { diff --git a/js/CLAUDE.md b/js/AGENTS.md similarity index 98% rename from js/CLAUDE.md rename to js/AGENTS.md index 9e0bf43c2a..f038f0c52d 100644 --- a/js/CLAUDE.md +++ b/js/AGENTS.md @@ -1,4 +1,4 @@ -The `/js` TypeScript workspace. Extends the root `CLAUDE.md`. +The `/js` TypeScript workspace. Extends the root `AGENTS.md`. # Packages diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index 90dae38c0c..903ab89762 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -8,18 +8,23 @@ const updates = 32; let checksum = 0; console.log("scope,routes,observers,update_us"); -for (const scope of ["unscoped", "distinct-scopes"] as const) { +for (const scope of ["unscoped", "distinct-scopes", "producer-scopes", "rooted-producers"] as const) { for (const routeCount of routeCounts) { for (const observerCount of observerCounts) { const origin = new Producer(); const handles = Array.from({ length: routeCount }, (_, index) => origin.dynamic(Path.from(`room/${index}`)), ); - const touched = Path.from("room/0"); + const touched = Path.from(scope === "rooted-producers" ? "0" : "room/0"); let notifications = 0; const disposes = Array.from({ length: observerCount }, (_, index) => - origin - .broadcasts(scope === "unscoped" ? undefined : Path.Pattern.parse(`room/**/tag-${index}`)) + (scope === "producer-scopes" + ? origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse(`room/**/tag-${index}`)])) + : scope === "rooted-producers" + ? origin.scope(Path.from("room"), new Path.Patterns([Path.Pattern.parse(`**/tag-${index}`)])) + : origin + ) + .broadcasts(scope === "distinct-scopes" ? Path.Pattern.parse(`room/**/tag-${index}`) : undefined) .subscribe((routes) => { if (routes.get(touched) === undefined) throw new Error("touched route disappeared"); notifications++; diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 32bc42ad8e..b744b042bb 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -266,7 +266,10 @@ export class Producer { this.#announcer?.unannounce(); } - /** Close the broadcast, optionally with an error to abort waiters. Idempotent. */ + /** End the broadcast for good: retract it, serve no new tracks, and refuse a later {@link announce}. Idempotent. */ + close(): void; + /** @deprecated A broadcast end carries no cause; call `close()` without one. */ + close(abort?: Error): void; close(abort?: Error) { this.#announcer?.unannounce(); this.#announcer = undefined; @@ -356,9 +359,12 @@ export class Consumer { } /** - * Release this handle. The broadcast is closed (optionally with an error to abort waiters) - * once this was the last live handle; while other {@link clone}s remain open it stays live. + * Release this handle. The broadcast is closed once this was the last live handle; + * while other {@link clone}s remain open it stays live. */ + close(): void; + /** @deprecated A broadcast end carries no cause; call `close()` without one. */ + close(abort?: Error): void; close(abort?: Error) { if (this.#closed) return; this.#closed = true; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index d9d9b74481..f115c38ca4 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -39,7 +39,7 @@ class FakeSession { }); } - announced(): Announce.Consumer { + announced(_scope?: Path.Pattern, _options?: Announce.Options): Announce.Consumer { return this.announces.consume(); } @@ -188,3 +188,98 @@ test("a request replaced across one coalesced wakeup still gets answered", async second.close(); origin.close(); }); + +test("scoped origins subscribe to disjoint literal heads with hidden routes", () => { + const origin = new OriginProducer().scope( + Path.empty(), + new Path.Patterns(["room/*/chat", "room/nested/**", "other/**"].map(Path.Pattern.parse)), + ); + const session = new FakeSession(); + const interests: { scope: string | undefined; hidden: boolean | undefined }[] = []; + session.announced = (scope?: Path.Pattern, options?: Announce.Options) => { + interests.push({ scope: scope?.text, hidden: options?.hidden }); + return session.announces.consume(); + }; + forwardAnnounced(session.session, origin); + expect(interests).toEqual([ + { scope: "other/**", hidden: true }, + { scope: "room/**", hidden: true }, + ]); + session.die(); + origin.close(); +}); + +test("an unscoped origin retains its subscription to every namespace", () => { + const origin = new OriginProducer(); + const session = new FakeSession(); + const interests: string[] = []; + session.announced = (scope?: Path.Pattern) => { + interests.push(scope?.text ?? "**"); + return session.announces.consume(); + }; + forwardAnnounced(session.session, origin); + expect(interests).toEqual(["**"]); + session.die(); + origin.close(); +}); + +test("a scoped session filters announcements and blind requests under its root", async () => { + const origin = new OriginProducer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); + const session = new FakeSession(); + forwardAnnounced(session.session, scoped); + for (const prefix of ["room/alice/video", "room/alice/chat"]) { + session.announces.append({ + prefix: Path.from(prefix), + captures: undefined, + kind: "announced", + route: Route.default, + }); + } + const allowed = origin.request(Path.from("tenant/room/bob/chat")); + const denied = origin.request(Path.from("tenant/room/bob/video")); + await settle(); + expect([...origin.broadcasts().peek().keys()]).toEqual([Path.from("tenant/room/alice/chat")]); + expect(session.consumes(Path.from("room/bob/chat"))).toBe(1); + expect(session.consumes(Path.from("room/bob/video"))).toBe(0); + const routed = origin.request(Path.from("tenant/room/alice/chat")); + await settle(); + expect(session.consumes(Path.from("room/alice/chat"))).toBe(1); + expect(routed.active.peek()).toBeDefined(); + allowed.close(); + denied.close(); + routed.close(); + session.die(); + origin.close(); +}); + +test("one failed scoped interest leaves the other routes live until the session ends", async () => { + const origin = new OriginProducer().scope( + Path.empty(), + new Path.Patterns(["a/**", "b/**"].map(Path.Pattern.parse)), + ); + const session = new FakeSession(); + const sources = new Map(); + session.announced = (scope?: Path.Pattern) => { + const producer = new Announce.Producer(); + sources.set(scope?.text ?? "**", producer); + return producer.consume(); + }; + forwardAnnounced(session.session, origin); + for (const name of ["a", "b"]) { + sources + .get(`${name}/**`) + ?.append({ prefix: Path.from(name), captures: undefined, kind: "announced", route: Route.default }); + } + await settle(); + expect(origin.broadcasts().peek().size).toBe(2); + sources.get("a/**")?.close(new Error("namespace rejected")); + await settle(); + expect([...origin.broadcasts().peek().keys()]).toEqual([Path.from("b")]); + expect(origin.discovery.peek()).toBe(false); + session.die(); + await settle(); + expect(origin.broadcasts().peek().size).toBe(0); + expect(origin.discovery.peek()).toBeUndefined(); + origin.close(); +}); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 66745398a9..f594d65fa5 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -6,7 +6,7 @@ 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"; +import * as Path from "../path.ts"; import { wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; @@ -35,6 +35,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi let detach = originWire.attach(conn.discovery); let dead = false; + let discovering = conn.discovery; void conn.closed.then(() => { dead = true; detach(); @@ -47,57 +48,61 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi return; } - // 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 - // error it, so the retractions below land promptly. - void conn.closed.then(() => announced.close()); - - void (async () => { - let failure: unknown; - try { - for (;;) { - const event = await announced.next(); - if (!event) break; - - if (isActive(event.kind)) { - const existing = inserted.get(event.prefix); - if (existing) { - existing.update(event.route); + for (const prefix of originWire.interests()) { + // Hidden routes are mirrored too; each local reader opts in on its own. + const announced = conn.announced(Path.Pattern.subtree(prefix), { hidden: true }); + const inserted = new Map(); + + // End the stream the moment the session closes rather than waiting for the wire to + // error it, so the retractions below land promptly. + void conn.closed.then(() => announced.close()); + + void (async () => { + let failure: unknown; + try { + for (;;) { + const event = await announced.next(); + if (!event) break; + if (!originWire.accepts(event.prefix)) continue; + + if (isActive(event.kind)) { + const existing = inserted.get(event.prefix); + if (existing) { + existing.update(event.route); + } else { + const handle = originWire.receive(event.prefix, event.route); + inserted.set(event.prefix, handle); + void drive(handle, conn); + } } else { - const handle = originWire.receive(event.prefix, event.route); - inserted.set(event.prefix, handle); - void drive(handle, conn); + const handle = inserted.get(event.prefix); + inserted.delete(event.prefix); + handle?.close(); } - } else { - const handle = inserted.get(event.prefix); - inserted.delete(event.prefix); - handle?.close(); + } + } catch (err) { + // The session died mid-stream, or the relay refused or reset the stream. The + // cleanup below retracts everything this stream fed either way. + failure = err; + } finally { + for (const handle of inserted.values()) handle.close(); + inserted.clear(); + announced.close(); + + // Discovery ended while the session lives, and nothing reopens the stream on this + // connection. Downgrade the attachment rather than leaving the origin claiming a + // discovery that no longer works: announcement-gated consumers would wait forever + // on a table this session can no longer fill. Now they fall back to standing + // requests, which this session still answers. + if (!dead && discovering) { + discovering = false; + console.warn("broadcast discovery failed; broadcasts resolve on request only.", failure); + detach(); + detach = originWire.attach(false); } } - } catch (err) { - // The session died mid-stream, or the relay refused or reset the stream. The - // cleanup below retracts everything this stream fed either way. - failure = err; - } finally { - for (const handle of inserted.values()) handle.close(); - inserted.clear(); - announced.close(); - - // Discovery ended while the session lives, and nothing reopens the stream on this - // connection. Downgrade the attachment rather than leaving the origin claiming a - // discovery that no longer works: announcement-gated consumers would wait forever - // on a table this session can no longer fill. Now they fall back to standing - // requests, which this session still answers. - if (!dead) { - console.warn("broadcast discovery failed; broadcasts resolve on request only.", failure); - detach(); - detach = originWire.attach(false); - } - } - })(); + })(); + } } /** diff --git a/js/net/src/connection/scope.test.ts b/js/net/src/connection/scope.test.ts new file mode 100644 index 0000000000..01bf4ab52a --- /dev/null +++ b/js/net/src/connection/scope.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import * as Ietf from "../ietf/index.ts"; +import * as Lite from "../lite/index.ts"; +import { createMockTransportPair } from "../mock.ts"; +import { Producer } from "../origin.ts"; +import * as Path from "../path.ts"; +import { withTimeout } from "../util/timeout.ts"; +import { accept } from "./accept.ts"; +import { connect } from "./connect.ts"; + +for (const protocol of [Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_19]) { + test(`scoped origins discover multiple prefixes over ${protocol}`, async () => { + const pair = createMockTransportPair(protocol); + const url = new URL("https://localhost/test"); + const source = new Producer(); + const destination = new Producer(); + const publish = source.scope(Path.from("server"), new Path.Patterns([Path.Pattern.all()])); + const consume = destination.scope( + Path.from("client"), + new Path.Patterns(["room/**", "other/**"].map(Path.Pattern.parse)), + ); + const broadcasts = ["room/live", "room/.hidden", "other/live", "outside"].map((path) => { + const broadcast = publish.createBroadcast(Path.from(path)); + broadcast.announce(); + return broadcast; + }); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client, consume }), + accept({ url, transport: pair.server, publish: publish.consume() }), + ]); + const announced = destination.announced(undefined, { hidden: true }); + try { + const received: string[] = []; + for (let index = 0; index < 3; index++) { + const update = await withTimeout(announced.next(), 1000, "scoped announcement did not arrive"); + if (!update) throw new Error("announcement stream ended before replay"); + expect(update.kind).toBe("announced"); + received.push(update.prefix); + } + expect(received.sort()).toEqual(["client/other/live", "client/room/.hidden", "client/room/live"]); + expect([...consume.broadcasts().peek().keys()].sort()).toEqual([ + Path.from("other/live"), + Path.from("room/live"), + ]); + } finally { + announced.close(); + client.close(); + server.close(); + for (const broadcast of broadcasts) broadcast.close(); + source.close(); + destination.close(); + await Promise.all([client.closed, server.closed]); + } + }); +} diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 914a4e8f4a..cf8418c0de 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -656,26 +656,24 @@ export class Subscriber { * 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. + * is only a hint: a peer may send 0 regardless, so 0 waits out the grace. A request + * stream that FINs without PUBLISH_DONE is a protocol violation. */ 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; + if (await stream.reader.done()) throw new ProtocolViolation("subscribe stream ended without PUBLISH_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}`); } + const count = done.streamCount; const { tail, track } = subscription; - const complete = () => count !== undefined && count > 0n && BigInt(tail.streams) >= count; + const complete = () => count > 0n && BigInt(tail.streams) >= count; await tail.settle(complete, TAIL_GRACE_MS, track.closed); } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index d7a505f642..da233686e7 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { ProtocolViolation } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Path from "../path.ts"; @@ -69,6 +70,7 @@ async function subscribed() { return { subscriber, reader, + fin: () => peer.writer.close(), done: async (statusCode: number, streamCount: bigint) => { await peer.writer.u53(PublishDone.id); await new PublishDone({ statusCode, streamCount, reasonPhrase: "done" }).encode(peer.writer, VERSION); @@ -175,3 +177,10 @@ test("END_OF_TRACK at object 0 ends the track before its group, which never exis expect(await reader.recvGroup()).toBeUndefined(); expect(reader.final()).toBe(2); }); + +test("a bare FIN without PUBLISH_DONE aborts the track", async () => { + const { reader, fin } = await subscribed(); + await fin(); + expect(await reader.closed).toBeInstanceOf(ProtocolViolation); + await expect(reader.recvGroup()).rejects.toThrow(ProtocolViolation); +}); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 59d578c377..057f8cda98 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -817,12 +817,16 @@ export class Subscriber { // Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording // the range the tail is accounted against. SUBSCRIBE_END declares the track's end right - // away, so a consumer learns it before the last groups arrive. Resolves on FIN and rejects - // when the stream is reset, so the track ends with the publisher's error rather than cleanly. + // away, so a consumer learns it before the last groups arrive. The publisher must declare + // the end before FIN; resets and malformed responses reject with their failure. async #runResponses(stream: Stream, entry: SubscribeEntry): Promise { for (;;) { const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); - if (!resp) return; + if (!resp) { + if (entry.end === undefined) + throw new ProtocolViolation("subscribe stream ended without SUBSCRIBE_END"); + return; + } if ("start" in resp) { entry.start = resp.start.group; diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index b74636c55c..4edfe88c5c 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; import { randomHop } from "../hop.ts"; import { createMockTransportPair } from "../mock.ts"; @@ -74,6 +75,7 @@ async function subscribed(maxAge = GRACE) { reader, respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, VERSION), fin: () => sub.writer.close(), + reset: (error: Error) => sub.writer.reset(error), }; } @@ -217,3 +219,22 @@ test("a SUBSCRIBE_END below a group already received aborts the track", async () const closed = await reader.closed; expect(closed).toBeInstanceOf(Error); }); + +for (const started of [false, true]) { + test(`a bare FIN ${started ? "after SUBSCRIBE_START" : "without responses"} aborts the track`, async () => { + const { reader, respond, fin } = await subscribed(); + if (started) await respond({ start: new SubscribeStart(0) }); + await fin(); + expect(await reader.closed).toBeInstanceOf(ProtocolViolation); + await expect(reader.recvGroup()).rejects.toThrow(ProtocolViolation); + }); +} + +test("a subscribe stream reset preserves the publisher's failure", async () => { + const { reader, reset } = await subscribed(); + reset(new StreamError(StreamCode.NotFound)); + const closed = await reader.closed; + expect(closed).toBeInstanceOf(StreamError); + expect((closed as StreamError).code).toBe(StreamCode.NotFound); + await expect(reader.recvGroup()).rejects.toThrow(StreamError); +}); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 405871d753..a85937941b 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -271,8 +271,9 @@ test("closing the origin closes every routed broadcast", async () => { expect(origin.closed.peek()).toBe(abort); expect(consumer.closed.peek()).toBe(abort); - expect(a.closed.peek()).toBe(abort); - expect(b.closed.peek()).toBe(abort); + // A broadcast end carries no cause, so the routed broadcasts close cleanly. + expect(a.closed.peek()).toBeNull(); + expect(b.closed.peek()).toBeNull(); expect(wireOf(consumer).routes(Path.from("a"))).toBe(false); expect(() => publish(origin, Path.from("late"))).toThrow(); @@ -1456,3 +1457,98 @@ test("a serving session closing releases quiet origin change listeners", async ( origin.close(); } }); + +test("a rooted producer shares the table and enforces its pattern union", async () => { + const origin = new Producer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*")])); + const broadcast = publish(scoped, Path.from("room/alice")); + expect(origin.broadcasts().peek().has(Path.from("tenant/room/alice"))).toBe(true); + expect([...scoped.broadcasts().peek().keys()]).toEqual([Path.from("room/alice")]); + const request = scoped.consume().request(Path.from("room/alice")); + expect(request.path).toBe(Path.from("room/alice")); + expect(request.active.peek()).toBeDefined(); + expect(() => scoped.createBroadcast(Path.from("other"))).toThrow("outside the origin scope"); + expect(() => scoped.request(Path.from("room/alice/deep"))).toThrow("outside the origin scope"); + expect(() => scoped.dynamic(Path.from("other"))).toThrow("outside the origin scope"); + expect(() => scoped.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("other/**")]))).toThrow( + "do not overlap", + ); + expect(() => origin.scope(Path.empty(), new Path.Patterns())).toThrow("do not overlap"); + + const nested = scoped.scope(Path.from("room"), new Path.Patterns([Path.Pattern.all()])); + expect([...nested.broadcasts().peek().keys()]).toEqual([Path.from("alice")]); + expect(() => nested.createBroadcast(Path.from("alice/deep"))).toThrow("outside the origin scope"); + const announced = nested.announced(); + expect((await announced.next())?.prefix).toBe(Path.from("alice")); + announced.close(); + request.close(); + broadcast.close(); + origin.close(); +}); + +test("a scoped dynamic only serves allowed paths and presents relative requests", async () => { + const origin = new Producer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); + const dynamic = scoped.dynamic(Path.from("room")); + expect(dynamic.prefix).toBe(Path.from("room")); + const denied = origin.request(Path.from("tenant/room/alice/video")); + expect(denied.unroutable.peek()).toBe(true); + const request = origin.request(Path.from("tenant/room/alice/chat")); + const requests = dynamic.requested(); + const pending = await requests.next(); + expect(pending.value?.path).toBe(Path.from("room/alice/chat")); + const broadcast = new BroadcastProducer(); + pending.value?.accept(broadcast); + expect(request.active.peek()).toBeDefined(); + dynamic.update({ cost: 2n }); + expect(origin.broadcasts().peek().get(Path.from("tenant/room"))?.cost.warm).toBe(2n); + await requests.return?.(); + dynamic.close(); + request.close(); + denied.close(); + broadcast.close(); + origin.close(); +}); + +test("scoped wire views filter and rebase advertisements and blind requests", () => { + const origin = new Producer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/**")])); + const local = publish(origin, Path.from("tenant/room/live")); + const hidden = publish(origin, Path.from("tenant/other/live")); + const allowed = origin.request(Path.from("tenant/room/missing")); + const denied = origin.request(Path.from("tenant/other/missing")); + expect([...(wireOf(scoped).requests.peek()?.keys() ?? [])]).toEqual([Path.from("room/missing")]); + expect([...(wireOf(scoped.consume()).advertised.peek()?.keys() ?? [])]).toEqual([Path.from("room/live")]); + expect(wireOf(scoped.consume()).local(Path.from("room/live"))).toBeDefined(); + expect(() => wireOf(scoped.consume()).local(Path.from("other/live"))).toThrow("outside the origin scope"); + allowed.close(); + denied.close(); + local.close(); + hidden.close(); + origin.close(); +}); + +test("a scoped dynamic is announced only to readers its scope can serve", () => { + const origin = new Producer(); + const dynamic = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/chat")])).dynamic(Path.empty()); + const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/chat")])); + const video = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/video")])); + expect([...chat.broadcasts().peek().keys()]).toEqual([Path.empty()]); + expect([...video.broadcasts().peek().keys()]).toEqual([]); + expect([...(wireOf(chat.consume()).advertised.peek()?.keys() ?? [])]).toEqual([Path.empty()]); + expect([...(wireOf(video.consume()).advertised.peek()?.keys() ?? [])]).toEqual([]); + dynamic.close(); + origin.close(); +}); + +test("a rooted reader presents the most specific covering route", () => { + const origin = new Producer(); + const narrow = origin.dynamic(Path.from("room/alice"), { cost: 9n }); + const broad = origin.dynamic(Path.from("room"), { cost: 1n }); + const rooted = origin.scope(Path.from("room/alice"), new Path.Patterns([Path.Pattern.all()])); + expect(rooted.broadcasts().peek().get(Path.empty())?.cost.warm).toBe(9n); + expect(wireOf(rooted.consume()).advertised.peek()?.get(Path.empty())?.route.cost.warm).toBe(9n); + broad.close(); + narrow.close(); + origin.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index b93d44e62f..741e43e304 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -22,6 +22,118 @@ import { type Advertised, registerWire, wireOf } from "./wire.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; +/** The rooted permissions shared by a handle and every route it inserts. */ +class Scope { + static readonly all = new Scope(Path.empty()); + + readonly root: Path.Valid; + readonly allowed?: Path.Patterns; + + constructor(root: Path.Valid, allowed?: Path.Patterns) { + this.root = root; + this.allowed = allowed; + } + + narrow(root: Path.Valid, patterns: Path.Patterns): Scope { + const joined = Path.encode(Path.join(this.root, root)); + const rooted = patterns.rooted(joined); + const allowed = this.allowed?.intersect(rooted) ?? rooted; + if (allowed.size === 0) throw new Error("origin scopes do not overlap"); + return new Scope(joined, allowed); + } + + matches(path: Path.Valid): boolean { + return this.allowed?.matches(path) ?? true; + } + + path(path: Path.Valid): Path.Valid { + const joined = Path.encode(Path.join(this.root, path)); + if (!this.matches(joined)) throw new Error("path is outside the origin scope"); + return joined; + } + + prefix(prefix: Path.Valid): Path.Valid { + const joined = Path.encode(Path.join(this.root, prefix)); + if (this.allowed && !this.allowed.overlaps(Path.Pattern.subtree(joined))) { + throw new Error("prefix is outside the origin scope"); + } + return joined; + } + + patterns(pattern: Path.Pattern = Path.Pattern.all()): Path.Patterns { + const rooted = new Path.Patterns([pattern.rooted(this.root)]); + return this.allowed?.intersect(rooted) ?? rooted; + } + + /** Omit nested heads because the outer subscription already carries their routes. */ + heads(): Path.Valid[] { + if (!this.allowed) return [Path.empty()]; + const heads = [...new Set([...this.allowed.rebase(this.root)].map(scopeHead))].sort(); + return heads.filter((head) => !heads.some((other) => other !== head && Path.hasPrefix(other, head))); + } + + /** The exact paths within this scope, relative to its root. */ + projectPaths(values: ReadonlyMap | undefined): ReadonlyMap | undefined { + if (!values || this === Scope.all) return values; + const out = new Map(); + for (const [path, value] of values) { + if (!this.matches(path)) continue; + const relative = Path.stripPrefix(this.root, path); + if (relative !== null) out.set(relative, value); + } + return out; + } + + /** The advertised prefixes that may serve this scope, relative to its root. */ + projectRoutes( + values: ReadonlyMap | undefined, + ): ReadonlyMap | undefined { + if (!values || this === Scope.all) return values; + const out = new Map(); + const covering = new CoveringRoot(this.root); + for (const [path, value] of values) { + if (this.allowed && ![...this.allowed].some((pattern) => advertOverlaps(value, path, pattern))) continue; + const relative = covering.relative(path); + if (relative !== undefined) out.set(relative, value); + } + return out; + } +} + +/** Whether the route advertised at `prefix` may serve any path `pattern` admits. */ +function advertOverlaps(advert: Advertised, prefix: Path.Valid, pattern: Path.Pattern): boolean { + return advert.claim ? advert.claim.overlaps(pattern) : scopeOverlaps(pattern, prefix); +} + +/** The paths `entry` may serve beneath `prefix`, when its producer is scoped. */ +function claimOf(entry: RouteEntry, prefix: Path.Valid): Path.Patterns | undefined { + return entry.scope.allowed?.intersect(new Path.Patterns([Path.Pattern.subtree(prefix)])); +} + +/** + * Presents advertised prefixes relative to a root. Every prefix at or above the root + * collapses to the empty path, where the most specific one wins, since that is the route + * a request beneath the root resolves through. + */ +class CoveringRoot { + readonly #root: Path.Valid; + #covering?: Path.Valid; + + constructor(root: Path.Valid) { + this.#root = root; + } + + /** The presented path for `path`, or undefined when it is outside the root or a broader cover. */ + relative(path: Path.Valid): Path.Valid | undefined { + const relative = Path.stripPrefix(this.#root, path); + if (relative !== null && relative !== Path.empty()) return relative; + if (relative === null && !Path.hasPrefix(path, this.#root)) return undefined; + if (this.#covering !== undefined && !Path.hasPrefix(this.#covering, path)) return undefined; + this.#covering = path; + return Path.empty(); + } +} + /** * One requested path: the notify node for everything watching it. * @@ -65,6 +177,7 @@ export interface RequestSlot { */ export interface RouteEntry { readonly identity: object; + readonly scope: Scope; readonly route: Signal; readonly originated: boolean; readonly server?: ServeState; @@ -107,6 +220,12 @@ function noCapacity(): StreamError { /** A served route from {@link Producer.dynamic}: the queue a handler drains. */ class ServeState { + readonly root: Path.Valid; + + constructor(root: Path.Valid) { + this.root = root; + } + queue = new Signal([]); pending = new Map(); served = new Map(); @@ -126,7 +245,7 @@ class ServeState { if (this.pending.has(path)) return; const live = this.served.get(path); if (live && live.closed.peek() === undefined) return; - const request = makeRequest(path, this); + const request = makeRequest(Path.stripPrefix(this.root, path) ?? Path.empty(), this); this.pending.set(path, request); this.queue.mutate((queue) => { queue.push(request); @@ -134,33 +253,35 @@ class ServeState { } accept(request: Request, front: broadcast.Consumer): void { - if (this.closed.peek() !== undefined || this.pending.get(request.path) !== request) { + const path = Path.join(this.root, request.path); + if (this.closed.peek() !== undefined || this.pending.get(path) !== request) { front.close(); return; } - this.pending.delete(request.path); - const existing = this.served.get(request.path); + this.pending.delete(path); + const existing = this.served.get(path); if (existing && existing.closed.peek() === undefined) { front.close(); - this.onChange(request.path); + this.onChange(path); this.settled.update((n) => n + 1); return; } - this.served.set(request.path, front); + this.served.set(path, front); void front.closed.then(() => { - if (this.served.get(request.path) !== front) return; - this.served.delete(request.path); - this.onChange(request.path); + if (this.served.get(path) !== front) return; + this.served.delete(path); + this.onChange(path); }); - this.onChange(request.path); + this.onChange(path); this.settled.update((n) => n + 1); } reject(request: Request, err: Error): void { - if (this.pending.get(request.path) !== request) return; - this.pending.delete(request.path); - if (this.demanding.has(request.path)) this.rejected.set(request.path, err); - this.onReject(request.path, err); + const path = Path.join(this.root, request.path); + if (this.pending.get(path) !== request) return; + this.pending.delete(path); + if (this.demanding.has(path)) this.rejected.set(path, err); + this.onReject(path, err); this.settled.update((n) => n + 1); } @@ -177,7 +298,7 @@ class ServeState { finishRequest(request, err); } for (const [path, front] of this.served) { - front.close(abort); + front.close(); this.onChange(path); } this.served.clear(); @@ -246,7 +367,7 @@ class OriginState { for (const [path, routes] of this.routes.peek() ?? []) { const entry = preferredEntry(routes); if (!entry) continue; - const value = { identity: entry.identity, route: entry.route.peek() }; + const value = { identity: entry.identity, route: entry.route.peek(), claim: claimOf(entry, path) }; remote.set(path, value); available.set(path, value.route); } @@ -358,7 +479,8 @@ class OriginState { const next = new Map(); for (const [prefix, entries] of routes ?? []) { const mine = preferredEntry(entries, received); - if (mine) next.set(prefix, { identity: mine.identity, route: mine.route.peek() }); + if (mine) + next.set(prefix, { identity: mine.identity, route: mine.route.peek(), claim: claimOf(mine, prefix) }); } // A local broadcast and an originated dynamic at one path compete on cost, as they do for requests. for (const [path, route] of advertised ?? []) { @@ -389,7 +511,10 @@ class OriginState { let best: RouteEntry | undefined; for (const [prefix, entries] of this.routes.peek() ?? []) { if (!Path.hasPrefix(prefix, path)) continue; - const entry = preferredEntry(entries, skip); + const entry = preferredEntry( + entries, + (candidate) => !candidate.scope.matches(path) || (skip?.(candidate) ?? false), + ); if (!entry) continue; if (bestPrefix === undefined || prefix.length > bestPrefix.length) { bestPrefix = prefix; @@ -509,27 +634,47 @@ export interface RequestOptions { */ export class Producer implements Table { #state = new OriginState(); + #scope = Scope.all; + #requests?: Getter | undefined>; // The reader backing the passthroughs, so holding a Producer never requires the // consume().x() stutter for everyday reads. One instance, so `discovery` keeps its // identity across reads. - #reader = makeConsumer(this.#state); + #reader = makeConsumer(this.#state, this.#scope); constructor() { const thisProducer = this; registerWire(this, { receive: (prefix, route) => this.#receive(prefix, route), + interests: () => this.#scope.heads(), + accepts: (prefix) => + !this.#scope.allowed || + this.#scope.allowed.overlaps(Path.Pattern.subtree(Path.join(this.#scope.root, prefix))), attach: (discovery) => this.#attach(discovery), expect: () => this.#expect(), get requests() { - return thisProducer.#state.requests; + if (thisProducer.#scope === Scope.all) return thisProducer.#state.requests; + thisProducer.#requests ??= new Derived([thisProducer.#state.requests], (requests) => + thisProducer.#scope.projectPaths(requests), + ); + return thisProducer.#requests; }, changed: () => this.#changed(), - answer: (path, front) => this.#answer(path, front), + answer: (path, front) => this.#answer(this.#scope.path(path), front), routes: (path) => wireOf(this.#reader).routes(path), }); } + /** Narrow this handle to patterns beneath root, presenting paths relative to that root. */ + scope(root: Path.Valid, patterns: Path.Patterns): Producer { + const scope = this.#scope.narrow(root, patterns); + const producer = new Producer(); + producer.#state = this.#state; + producer.#scope = scope; + producer.#reader = makeConsumer(this.#state, scope); + return producer; + } + /** * Settles once the origin closes: `null` on a clean close, or the abort {@link Error}. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. @@ -552,6 +697,7 @@ export class Producer implements Table { * a remote route at the same path on cost, winning ties. */ createBroadcast(path: Path.Valid): broadcast.Producer { + path = this.#scope.path(path); const created = this.#state.created; if (!created) throw new Error("origin is closed"); @@ -638,10 +784,12 @@ export class Producer implements Table { } #insertRoute(prefix: Path.Valid, route: Route, originated: boolean): Dynamic { - const server = new ServeState(); + prefix = this.#scope.prefix(prefix); + const server = new ServeState(this.#scope.root); server.onChange = (path) => this.#state.refresh(path); const entry: RouteEntry = { identity: {}, + scope: this.#scope, route: new Signal(route), originated, server, @@ -777,7 +925,7 @@ export class Producer implements Table { /** A read handle for this origin, the side a connection's `publish` option borrows. */ consume(): Consumer { - return makeConsumer(this.#state); + return makeConsumer(this.#state, this.#scope); } /** Whether every attached session announces into the table; see {@link Consumer.discovery}. */ @@ -805,7 +953,7 @@ export class Producer implements Table { if (this.#state.closed.peek() !== undefined) return; this.#state.closed.set(abort ?? null); for (const front of this.#state.created?.values() ?? []) { - front.close(abort); + front.close(); } this.#state.created = undefined; this.#state.local.update(() => undefined); @@ -838,7 +986,7 @@ export class Producer implements Table { // Constructs a Consumer from within this module without exposing a public constructor // that would leak the unexported OriginState. Assigned in the class's static block. -let makeConsumer: (state: OriginState) => Consumer; +let makeConsumer: (state: OriginState, scope: Scope) => Consumer; // Same for Requesting: a public constructor would let a caller forge a handle that no origin // ever registered, whose lifecycle guarantees are then false. `@internal` alone would not @@ -940,9 +1088,11 @@ export class Requesting { */ export class Consumer { #state: OriginState; + #scope: Scope; - private constructor(state: OriginState) { + private constructor(state: OriginState, scope: Scope) { this.#state = state; + this.#scope = scope; // True only when every attached session announces. One session that cannot means the // table is an incomplete picture, so a consumer gated on it has to keep its blind // fallback: the paths only that session carries never reach the table at all. @@ -950,20 +1100,20 @@ export class Consumer { total === 0 ? undefined : discovery === total, ); registerWire(this, { - routes: (path) => this.#routes(path), - get broadcasts() { - return state.local; - }, - get advertised() { - return state.originated; - }, - local: (path) => this.#local(path), - demand: (path) => this.#demand(path), + routes: (path) => this.#routes(scope.path(path)), + broadcasts: + scope === Scope.all ? state.local : new Derived([state.local], (local) => scope.projectPaths(local)), + advertised: + scope === Scope.all + ? state.originated + : new Derived([state.originated], (routes) => scope.projectRoutes(routes)), + local: (path) => this.#local(scope.path(path)), + demand: (path) => this.#demand(scope.path(path)), }); } static { - makeConsumer = (state) => new Consumer(state); + makeConsumer = (state, scope) => new Consumer(state, scope); } /** Settles once the origin closes; see {@link Producer.closed}. */ @@ -1025,12 +1175,14 @@ export class Consumer { * feeds from more than one connection. */ request(path: Path.Valid, options: RequestOptions = {}): Requesting { + const relative = path; + path = this.#scope.path(path); const requests = this.#state.requests.peek(); if (!requests) { // Closed origin: a request that can never resolve, and says so. const closed = new Once(); return makeRequesting( - path, + relative, new Signal(undefined), getter(true), closed, @@ -1116,7 +1268,7 @@ export class Consumer { (front, answerers, ended) => ended !== undefined || (!front && answerers === 0), ); - return makeRequesting(path, active, unroutable, closed, () => { + return makeRequesting(relative, active, unroutable, closed, () => { // Releases this request's handle; the route itself belongs to the table. released = true; unsubscribeDiscovery(); @@ -1158,10 +1310,11 @@ export class Consumer { */ broadcasts(scope?: Path.Pattern, options?: announce.Options): Getter> { const hidden = options?.hidden ?? false; - if (!scope) return hidden ? this.#state.available : this.#state.visible; + if (!scope && this.#scope === Scope.all) return hidden ? this.#state.available : this.#state.visible; + const patterns = this.#scope.patterns(scope); return new Derived([this.#state.available], () => { const routes = new Map(); - for (const [path, entry] of this.#listed(scope, hidden)) routes.set(path, entry.route); + for (const [path, entry] of this.#listed(patterns, hidden)) routes.set(path, entry.route); return routes; }); } @@ -1176,29 +1329,42 @@ export class Consumer { */ announced(scope: Path.Pattern = Path.Pattern.all(), options?: announce.Options): announce.Consumer { const producer = new announce.Producer(); - void this.#runAnnounced(producer, scope, options?.hidden ?? false); + void this.#runAnnounced(producer, this.#scope.patterns(scope), options?.hidden ?? false); return producer.consume(); } /** One snapshot shared by map readers and announcement-stream diffing. */ - #listed(scope: Path.Pattern, hidden: boolean): Map { + #listed(patterns: Path.Patterns, hidden: boolean): Map { const next = new Map(); + const covering = new CoveringRoot(this.#scope.root); const { remote, local } = this.#state.snapshot(); - const head = scopeHead(scope); - for (const [path, entry] of remote) { - if (!scopeOverlaps(scope, path)) continue; - if (!hidden && hiddenBelow(head, path)) continue; - next.set(path, { identity: entry.identity, route: entry.route, captures: scopeCaptures(scope, path) }); - } - for (const [path, entry] of local) { - if (!scope.matches(path)) continue; - if (!hidden && hiddenBelow(head, path)) continue; - next.set(path, { identity: entry.identity, route: entry.route, captures: scopeCaptures(scope, path) }); + const scopes = [...patterns] + .sort((a, b) => Path.compareSpecificity(b.specificity(), a.specificity())) + .map((pattern) => ({ pattern, head: scopeHead(pattern) })); + for (const [table, exact] of [ + [remote, false], + [local, true], + ] as const) { + for (const [path, entry] of table) { + const scope = scopes.find( + ({ pattern, head }) => + (exact ? pattern.matches(path) : advertOverlaps(entry, path, pattern)) && + (hidden || !hiddenBelow(head, path)), + ); + if (!scope) continue; + const relative = covering.relative(path); + if (relative === undefined) continue; + next.set(relative, { + identity: entry.identity, + route: entry.route, + captures: scopeCaptures(scope.pattern, path), + }); + } } return next; } - async #runAnnounced(producer: announce.Producer, scope: Path.Pattern, hidden: boolean): Promise { + async #runAnnounced(producer: announce.Producer, patterns: Path.Patterns, hidden: boolean): Promise { // Keyed by the presented path (from the origin, not the scope), valued by identity // plus route. Diffing identity rather than mere presence means a republish emits a // retraction then a fresh announcement; a re-price of the same identity emits an @@ -1212,7 +1378,7 @@ export class Consumer { const routes = this.#state.routes.peek(); if (local === undefined && advertisedLocal === undefined && routes === undefined) break; - const next = this.#listed(scope, hidden); + const next = this.#listed(patterns, hidden); for (const [path, snap] of active) { const cur = next.get(path); @@ -1335,7 +1501,7 @@ export class Dynamic { #closed = false; private constructor(prefix: Path.Valid, entry: RouteEntry, state: OriginState, retract: Dispose) { - this.prefix = prefix; + this.prefix = Path.stripPrefix(entry.scope.root, prefix) ?? Path.empty(); this.#entry = entry; this.#state = state; this.#retract = retract; @@ -1350,7 +1516,7 @@ export class Dynamic { if (this.#closed) throw new Error("dynamic is closed"); this.#entry.route.set(Route.normalize(route)); this.#state.rebuildOriginated(); - this.#state.refreshPrefix(this.prefix); + this.#state.refreshPrefix(Path.join(this.#entry.scope.root, this.prefix)); this.#state.routes.mutate(() => {}); } diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 81dfe0f9c7..af77e0cde7 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -25,6 +25,10 @@ export interface Broadcast { /** The protocol-facing operations behind an origin producer. */ export interface OriginProducer { + /** Minimal root-relative namespace prefixes that cover this origin scope. */ + interests(): readonly Path.Valid[]; + /** Whether an advertised prefix overlaps the handle's allowed paths. */ + accepts(prefix: Path.Valid): boolean; receive( prefix: Path.Valid, route?: Route | { hops?: Route["hops"]; cost?: Route["cost"] | bigint }, @@ -51,6 +55,8 @@ export interface OriginConsumer { export interface Advertised { readonly identity: object; readonly route: Route; + /** The absolute paths a scoped route may serve beneath its prefix; unset for the whole subtree. */ + readonly claim?: Path.Patterns; } /** The protocol-facing operation behind an established session. */ diff --git a/justfile b/justfile index 22f4d7084c..9d8af7a663 100644 --- a/justfile +++ b/justfile @@ -248,7 +248,7 @@ install: # Resolve BASE: arg > $GITHUB_BASE_REF > upstream > origin/main. A branch's # upstream is the branch it merges into, which is the base a `dev`-targeted # branch needs. `git push -u` repoints upstream at the branch's own remote copy, -# which would diff HEAD against itself, so ignore that case (see CLAUDE.md). +# which would diff HEAD against itself, so ignore that case (see AGENTS.md). # GITHUB_BASE_REF outranks the upstream because a PR checkout has no upstream # configured, and the branch being merged into is exactly the base GitHub is # asking about. diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 6b92963a40..a26a819658 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -62,7 +62,7 @@ typealias GroupProducer = uniffi.moq.MoqGroupProducer typealias GroupConsumer = uniffi.moq.MoqGroupConsumer // Media (codec-aware) producers and consumers. -/** The write side of a media track fed pre-framed payloads. */ +/** The write side of a media track; discontinuity() marks a break between pre-framed payloads. */ typealias MediaProducer = uniffi.moq.MoqMediaProducer /** The write side of a media track fed a raw byte stream, with frame boundaries inferred. */ typealias MediaStreamProducer = uniffi.moq.MoqMediaStreamProducer diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 728c381c8e..dec946bc2f 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -22,7 +22,8 @@ class Moq internal constructor( /** * Create an unannounced broadcast at [path], invisible to everyone until announced. * - * Advertise it with `announce` after populating tracks. `finish()` unpublishes immediately. + * Advertise it with `announce` after populating tracks. `close()` (or `use`) ends it once no + * `dynamic()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer = session.publish().createBroadcast(path) diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt index e085291a69..9257ba51dd 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt @@ -33,8 +33,8 @@ class Server internal constructor( * Create a live broadcast at [path], served to incoming sessions. * * The origin announces the path so subscribers can discover it, becoming visible - * Advertise it with `announce` after populating tracks. `finish()` - * unpublishes immediately. + * Advertise it with `announce` after populating tracks. `close()` (or `use`) + * ends it once no `dynamic()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer { val origin = publishOrigin ?: throw IllegalStateException("no publish origin configured") diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index e9852274c9..19efd52640 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -132,6 +132,16 @@ class SmokeTest { assertEquals(ConnectionStatus.CONNECTED, status) } + @Test + fun `closing a broadcast ends it, and closing again is a no-op`() = runTest { + val broadcast = BroadcastProducer() + val consumer = broadcast.consume() + // AutoCloseable.close() releases the last producer handle, which ends the broadcast. + broadcast.close() + broadcast.close() + assertFailsWith { consumer.subscribeTrack("events", null) } + } + @Test fun `broadcast updates shared video properties`() { BroadcastProducer().use { broadcast -> @@ -302,7 +312,6 @@ class SmokeTest { server.createBroadcast("live").use { broadcast -> broadcast.announce(Route()) broadcast.unannounce() - broadcast.finish() } } } diff --git a/nix/overlay.nix b/nix/overlay.nix index b3fb9ce61b..961f1e72d6 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -167,29 +167,15 @@ let mkdir -p $out/lib/pkgconfig $out/include $out/lib/cmake/moq - # build.rs derives its output dir from OUT_DIR, so a cross --target - # build puts the staticlib and pkgconfig (under /lib/) below - # target//, and the shared header under target//include/. - # Keep the prefix target-aware so the native and cross outputs share one - # installPhase. - tdir="target''${CARGO_BUILD_TARGET:+/$CARGO_BUILD_TARGET}" - cp "$tdir/release/libmoq.a" $out/lib/ - cp "$tdir/include/moq.h" $out/include/ - cp "$tdir/release/lib/pkgconfig/moq.pc" $out/lib/pkgconfig/ - - # build.rs points libdir at the raw cargo target tree's profile dir - # (../.. from the .pc). The installPhase puts the staticlib in $out/lib - # alongside pkgconfig/, so rewrite libdir one level up. Match the whole - # line so this is independent of the profile name and the exact .pc - # template. Stays relocatable; no build-time path leaks into the store. - sed -i 's#^libdir=.*#libdir=''${pcfiledir}/..#' $out/lib/pkgconfig/moq.pc - - # Same relocation for includedir: the template points at the cargo tree's - # shared target/include (../../../ from the .pc). Here the header lives in - # $out/include, one level up from $out/lib, so it's ../../ from the .pc. - # Without this, pkg-config --cflags emits a bogus -I above $out and - # consumers fail with "moq.h: No such file or directory". - sed -i 's#^includedir=.*#includedir=''${pcfiledir}/../../include#' $out/lib/pkgconfig/moq.pc + # Ask cargo's build log where it put things instead of reconstructing the + # paths, which a cross --target build moves. build.rs lays out its + # OUT_DIR like this prefix, minus the staticlib. + jq=${final.lib.getExe final.jq} + lib=$($jq -r 'select(.reason == "compiler-artifact") | .filenames[] | select(endswith("/libmoq.a"))' "$cargoBuildLog") + gen=$($jq -r 'select(.reason == "build-script-executed") | select(.package_id | test("libmoq")) | .out_dir' "$cargoBuildLog") + cp "$lib" $out/lib/ + cp "$gen/include/moq.h" $out/include/ + cp "$gen/lib/pkgconfig/moq.pc" $out/lib/pkgconfig/ major_version="$(echo "${libmoqInfo.version}" | cut -d. -f1)" substitute ${../rs/libmoq/cmake/moq-config.cmake.in} \ diff --git a/py/CLAUDE.md b/py/AGENTS.md similarity index 92% rename from py/CLAUDE.md rename to py/AGENTS.md index b14e3dc6c2..58bd95b8f0 100644 --- a/py/CLAUDE.md +++ b/py/AGENTS.md @@ -1,4 +1,4 @@ -The `/py` uv workspace. Extends the root `CLAUDE.md` and `rs/moq-ffi/CLAUDE.md`. +The `/py` uv workspace. Extends the root `AGENTS.md` and `rs/moq-ffi/AGENTS.md`. # Packages diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 6add3e7adb..0902031a5a 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -68,7 +68,7 @@ async def main(): # Clean up audio.finish() - broadcast.finish() + broadcast.close() asyncio.run(main()) @@ -132,7 +132,7 @@ client = moq.Client( - **`Server(bind="[::]:443", *, tls_cert=(), tls_key=(), tls_generate=(), publish=None, subscribe=None)`**. Async context manager + async iterator of incoming `Request`s. - `.local_addr`. The bound address (useful when binding to port `0`). - `.cert_fingerprints()`. SHA-256 fingerprints of the configured TLS certificates, for `serverCertificateHashes` browser cert pinning. - - `.create_broadcast(path) → BroadcastProducer`. Create an unannounced broadcast, invisible to everyone; `announce()` makes it discoverable and reachable; `finish()` unpublishes it. + - `.create_broadcast(path) → BroadcastProducer`. Create an unannounced broadcast, invisible to everyone; `announce()` makes it discoverable and reachable; `close()` ends it. - **`Request`**. An incoming session, yielded by `async for request in server`. - `.url`, `.path`, `.query`, `.transport`. The query-free path is uniform across transports; the root or missing path is `""`. The encoded query may contain credentials. - `.set_publish(origin)`, `.set_consume(origin)`. Per-request overrides, captured at `accept()`. Raise if the request is already answered, cancelled, or currently accepting. @@ -155,7 +155,7 @@ client = moq.Client( - `.publish_video(format, init=b"", *, label=None, hint=None, track=None) → MediaProducer`. `init` may be empty for a format that resolves in band; a `VideoHint` pins catalog fields the stream can't reveal (bitrate) or publishes the catalog before the first keyframe. `track` names the track as in `publish_audio`. - `.encode_video(input, output, *, bandwidth=None) → VideoProducer`. Encode raw `VideoFrame`s inside the binding; `.write(frame)` each one. - `.encode_audio(name, input, output, *, bandwidth=None) → AudioProducer`. Encode raw PCM `AudioFrame`s; the codec is `output.codec`, e.g. `AudioCodec.opus()`, with `output.frame_duration_us` setting the Opus frame length. - - `.finish()` + - `.close()` ends the broadcast for good; a second call is a no-op. `.finish()` is its deprecated alias. - **`BroadcastDynamic`**. Async source of tracks requested by subscribers. - `await .requested_track() → TrackRequest`. Call `.accept()` on it for a `TrackProducer`, or `.abort(code)` to reject. - Async iterator yielding `TrackRequest` diff --git a/py/moq-rs/moq/origin.py b/py/moq-rs/moq/origin.py index f4a7c1886b..517ebe1210 100644 --- a/py/moq-rs/moq/origin.py +++ b/py/moq-rs/moq/origin.py @@ -248,8 +248,7 @@ def create_broadcast(self, path: str) -> BroadcastProducer: and peers alike, until :meth:`BroadcastProducer.announce`. Announce it after populating tracks. Create, :meth:`dynamic` if tracks are served on demand, populate, then announce. - ``finish()`` unpublishes immediately, while dropping the producer without - finishing also unpublishes but reads to subscribers as a failure rather - than a deliberate end. + :meth:`BroadcastProducer.close` ends it for good; dropping the last + handle does the same. """ return BroadcastProducer._from_inner(self._inner.create_broadcast(path)) diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index cbaf29ba13..322009e6a0 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import warnings from typing import TYPE_CHECKING, Any from moq_ffi import ( @@ -102,6 +103,10 @@ def flush(self, timestamp_us: int) -> None: """ self._inner.flush(timestamp_us) + def discontinuity(self) -> None: + """Mark a timeline break and restart handoff measurement, preserving advertised jitter.""" + self._inner.discontinuity() + def cut(self) -> None: """Draw a group boundary here. @@ -839,6 +844,14 @@ def consume(self) -> BroadcastConsumer: return BroadcastConsumer(self._inner.consume()) + def close(self) -> None: + """End the broadcast for good: retract it and serve no new tracks. + + Tracks already subscribed carry on to their own end. Closing again is a no-op. + """ + self._inner.close() + def finish(self) -> None: - """Finish the broadcast, closing its tracks and unpublishing it.""" - self._inner.finish() + """Deprecated: use :meth:`close`. A broadcast end carries no cause.""" + warnings.warn("use close(); a broadcast end carries no cause", DeprecationWarning, stacklevel=2) + self._inner.close() diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index dd4218ebe8..0c5238471b 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -127,7 +127,7 @@ def test_publish_media_lifecycle(): media = broadcast.publish_audio(moq.AudioFormat.OPUS, opus_head()) media.write_frame(b"opus frame", 1000) media.finish() - broadcast.finish() + broadcast.close() def test_publish_media_cut_and_seek(): @@ -146,9 +146,11 @@ def test_publish_media_cut_and_seek(): # The same boundary, with the next group explicitly numbered. media.write_frame(b"opus frame", 60_000) media.seek(42) + media.discontinuity() + media.write_frame(b"resumed opus frame", 100_000) media.finish() - broadcast.finish() + broadcast.close() def test_video_properties_use_defaulted_fields(): @@ -157,7 +159,7 @@ def test_video_properties_use_defaulted_fields(): assert properties.display is None assert properties.flip is None broadcast.set_video_properties(properties) - broadcast.finish() + broadcast.close() def test_audio_rejects_bad_init_bytes(): @@ -303,12 +305,19 @@ async def test_catalog_update_on_new_track(): break -def test_finish_closes_producer(): +def test_close_twice_is_a_noop(): broadcast = moq.BroadcastProducer() _media = broadcast.publish_audio(moq.AudioFormat.OPUS, opus_head()) - broadcast.finish() + broadcast.close() + broadcast.close() with pytest.raises(Exception): + broadcast.publish_audio(moq.AudioFormat.OPUS, opus_head()) + + +def test_finish_is_deprecated(): + broadcast = moq.BroadcastProducer() + with pytest.deprecated_call(): broadcast.finish() @@ -330,7 +339,7 @@ def test_publish_lifecycle(): track = broadcast.publish_track("status") track.write_frame(b'{"cmd": "ready"}', 0) track.finish() - broadcast.finish() + broadcast.close() async def test_publish_track_info_and_subscription(): @@ -520,7 +529,7 @@ async def test_dynamic_broadcast_request(): assert frame.payload == payload assert frame.timestamp_us == 20_000 track.finish() - served.finish() + served.close() async def test_dynamic_broadcast_request_can_reject(): @@ -1075,7 +1084,7 @@ def test_encode_audio_with_opus_object(): producer.write(moq.AudioFrame(timestamp_us=0, data=bytes(960 * 4))) assert producer.name == "mic" producer.finish() - broadcast.finish() + broadcast.close() async def test_decode_video_frame(): @@ -1123,7 +1132,7 @@ async def test_decode_video_frame(): assert all(packed[i] == 0xFF for i in range(3, len(packed), 4)), "RGBA output should be opaque" video.finish() - broadcast.finish() + broadcast.close() async def test_broadcast_is_reachable_only_while_announced(): @@ -1153,7 +1162,7 @@ async def test_broadcast_is_reachable_only_while_announced(): await asyncio.wait_for(consumer.request_broadcast("live"), timeout=5.0) announced.cancel() track.finish() - broadcast.finish() + broadcast.close() async def test_announced_pattern_captures(): @@ -1174,8 +1183,8 @@ async def test_announced_pattern_captures(): announced.cancel() dynamic.cancel() - audio.finish() - chat.finish() + audio.close() + chat.close() async def test_dynamic_serves_a_request_under_a_prefix(): @@ -1190,7 +1199,7 @@ async def test_dynamic_serves_a_request_under_a_prefix(): request.accept(served) await asyncio.wait_for(pending, timeout=5.0) dynamic.cancel() - served.finish() + served.close() async def test_dynamic_and_json_handles_are_async_context_managers(): @@ -1229,4 +1238,4 @@ async def assert_cancelled(awaitable) -> None: snapshot.finish() stream.finish() track.finish() - broadcast.finish() + broadcast.close() diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index 170129306b..3dd07bc8a9 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -72,7 +72,7 @@ async def accept_loop() -> None: except asyncio.CancelledError: pass media.finish() - broadcast.finish() + broadcast.close() async def test_client_reconnects_and_resumes_announcements(): @@ -127,7 +127,7 @@ async def accept_loop() -> None: async for announcement in client.announced(): assert announcement.prefix == "after-reconnect" break - broadcast.finish() + broadcast.close() finally: accept_task.cancel() try: @@ -287,7 +287,7 @@ async def test_serve_helper_accepts_clients(): await serve_task except asyncio.CancelledError: pass - broadcast.finish() + broadcast.close() async def test_broadcast_route_over_wire(): @@ -317,7 +317,7 @@ async def test_broadcast_route_over_wire(): await serve_task except asyncio.CancelledError: pass - broadcast.finish() + broadcast.close() async def test_route_update_observes_restart(): diff --git a/quest/CLAUDE.md b/quest/AGENTS.md similarity index 99% rename from quest/CLAUDE.md rename to quest/AGENTS.md index 2bb77f82bb..16eebac7aa 100644 --- a/quest/CLAUDE.md +++ b/quest/AGENTS.md @@ -20,7 +20,7 @@ durable scope or coordination. merges into its line's branch, a line into its parent's, and a milestone's direct children into `main`. Milestones have no branch. - A published API or wire break retargets to `dev` at PR time, per the root - `CLAUDE.md`; a quest's Plan may note it. + `AGENTS.md`; a quest's Plan may note it. - Every `Quests` list is ordered by priority. Insert at rank, never append. - Link with root-absolute paths. Finished documents are deleted; git history keeps them. Merge conflicts are expected; resolve them by aligning quests. diff --git a/quest/m1/README.md b/quest/m1/README.md index f7c6309748..5f91306218 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,26 +17,25 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests +- [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` -- [JS bare FIN](/quest/m1/js-bare-fin.md) - a `@moq/net` subscriber aborts a track whose subscribe stream FINs before its declared end, like Rust +- [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` -- [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for - [Origin mount](/quest/m1/origin-mount.md) - a session sees a granted subtree from outside its root under a path inside it, read-only - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine -- [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing +- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone -- [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone -- [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer -- [JS origin scope](/quest/m1/js-origin-scope.md) - a scoped `@moq/net` origin subscribes only to its allowed prefixes +- [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name +- [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack +- [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer - [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux -- [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers @@ -50,6 +49,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [C++ through moq-ffi](/quest/m1/cpp/README.md) - generated C++ over moq-ffi with futures and expected-style errors, shipped as a tarball, vcpkg, and Conan, and adopted by the OBS plugin - [moq-c](/quest/m1/moq-c.md) - libmoq ships as `moq-c`, beside `moq-cpp`, with its C header and library unchanged - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders +- [Opus concealment](/quest/m1/opus-conceal.md) - a lost Opus packet conceals the last packet's length, not 120 ms - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain - [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name @@ -61,11 +61,14 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Own the QUIC stack](/quest/m1/quic/README.md) - the moq-noq fork carries ACK progress, reliable reset, hierarchical scheduling, deadlines, probing, keep-alive, peer limits, careful resume, ECN, and qmux +- [BBR idle burst](/quest/m1/bbr-idle-burst.md) - a BBRv3 burst after a long idle paces near the learned bandwidth, proven by a fork regression - [P2P](/quest/m1/p2p/README.md) - opted-in clients serve each other over data channels and iroh while the relay stays the rendezvous and the fallback, under application policy - [One port](/quest/m1/one-port/README.md) - a relay speaks QUIC, STUN, WebRTC media, and SRT on one UDP port and HTTP, RTMP, and RTMPS on one TCP port - [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants - [Stream sessions](/quest/m1/uring-tcp/README.md) - serve WebSocket and HTTP from the io_uring workers, where io_uring pays off most - [IETF on the ring](/quest/m1/uring-ietf.md) - the io_uring workers serve moq-transport sessions too, so a uring relay drops no client protocol +- [JS timeline scans](/quest/m1/js-timeline-scans.md) - a `@moq/net` publisher's per-group cost stays flat as the retained window grows +- [BBR ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - packet bookkeeping scales with completed entries instead of scanning the flight on every ACK - [Perf](/quest/m1/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model - [#2924](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every listener on both runtimes shares one reloadable served identity, so rotation is atomic and generate works with workers - [#2964](/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md) - integrate the dev worker owner with hardened socket-group formation diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 7042f61feb..6ec02ba9d1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,7 +39,7 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - [Origin narrowing](/quest/m1/origin-narrowing.md) owns the common resize + [Origin narrowing](/quest/m1/auth/narrowing.md) owns the common resize operation; relay token handling requires it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an @@ -96,6 +96,9 @@ existing lite-06 ALPN. each lite-06 cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code +- [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in + place: subscriptions outside it reset, publishes outside it abort, and relay + revalidation stops closing the session - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access - [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION @@ -113,8 +116,6 @@ existing lite-06 ALPN. ## Related -- [Origin narrowing](/quest/m1/origin-narrowing.md) - resizes a live session - when the union shrinks, for revalidation and token expiry alike - [Pattern interest](/quest/m1/path-patterns.md) - moves AUTH's legacy wire prefixes to patterns along with ANNOUNCE_REQUEST - [Expiring media grants](/quest/m1/processor/grant-lease.md) - a worker's lease renewal is a new in-band token diff --git a/quest/m1/auth/narrowing.md b/quest/m1/auth/narrowing.md new file mode 100644 index 0000000000..adcf5db209 --- /dev/null +++ b/quest/m1/auth/narrowing.md @@ -0,0 +1,58 @@ +# [M] A live origin grant narrows in place + +## Goal + +A `moq-net` session's grant can be replaced by a narrower pattern union +while the session is live: announce cursors retract the patterns that fell +outside, resolution refuses them, subscriptions under them reset with +`UNAUTHORIZED`, and broadcasts the session published under them abort. The +relay uses it on revalidation so a moderation decision (a deafened user's +audio path) takes effect on the session it targets, which a forked client +cannot bypass. This is the per-subscriber exclusion +[#2714](https://github.com/moq-dev/moq/issues/2714) asked for; the scope and +match half already landed in #3672 and #3746. + +A narrowing always succeeds. A changed root still closes the session. + +## Plan + +- Enforce per session, not per handout. The line's session gate for received + grants (`auth::Gate`, the lite publisher resetting subscriptions it denies, + `regrant` retracting announces no longer covered, and the IETF equivalent) + is the same mechanism pointed at the relay's own grant. Reuse it rather than + building a second one. Readers that bypass the session publisher (HTTP + `/fetch` holds its own lease and closes on it; moq-hls and WHEP enforce no + per-user grant today) are out of scope. +- #3971 built narrowing alone and was closed: it refused whenever anything + live sat in the removed part, which is exactly when a grant narrows, and it + kept the ceiling in a side map. Scopes today are copied by value into every + handle and cursor (`OriginScope` in `rs/moq-net/src/model/origin.rs`), so + narrowing needs shared state. If handles need a tree to share it, keep the + ceiling on the grant node itself (an `Arc` shared by clones and children). +- Publish side: routes and broadcasts the session published outside the + narrowed grant abort, so consumers see `Unauthorized` just as the subscribe + side does. Draining is not an option: a group may stay open as long as its + track. +- Relay revalidation uses it: a re-checked grant with the same root narrows + the session instead of closing it (`Lease::ended` reporting + `Reason::Narrowed` in `rs/moq-relay/src/auth.rs`), on every accept path: native, + io_uring, and `moq --listen` share `connection::supervise`, while WebSocket + has its own copy of the loop. +- Prove the deafen case end to end: subscribe under a room prefix, narrow + with a grant that excludes that audio path, and assert the subscription + resets with `UNAUTHORIZED` and no further objects arrive, while a sibling + path under the same prefix keeps flowing. +- Benchmark the narrowing and per-session gate cost in + `rs/moq-net/benches/origin.rs`, swept over routes and subscribers, with a + session that never narrows as the baseline. + +Public API: additive on moq-net and on moq-relay's revalidate path. Wire: +none beyond the UNAUTHORIZED code the required quest adds. + +## Required + +- [Unauthorized reset](/quest/m1/auth/unauthorized.md) - the stream code a narrowed subscription resets with + +## Closes + +- [#2714](https://github.com/moq-dev/moq/issues/2714) - close this issue when the quest finishes diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 36d14f7c07..cff68ecc51 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -34,7 +34,7 @@ expiry, an expiry that leaves the union intact ends only that token, and publish-only session never starts receiving announcements because a later token happened to carry subscribe prefixes, and swapped in whenever the set grows; keep it behind one function, and a shrinking union goes through - [Origin narrowing](/quest/m1/origin-narrowing.md) rather than a close. + [Origin narrowing](/quest/m1/auth/narrowing.md) rather than a close. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session @@ -80,7 +80,7 @@ Additive. ## Required -- [Origin narrowing](/quest/m1/origin-narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships +- [Origin narrowing](/quest/m1/auth/narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships - [Pattern interest](/quest/m1/path-patterns.md) - AUTH can represent the complete grants relay revalidation returns - [Lite stream](/quest/m1/auth/lite.md) - supplies the AUTH stream and `auth::Request` this consumes diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index c39486256a..c6125f8295 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - [Origin narrowing](/quest/m1/origin-narrowing.md). + [Origin narrowing](/quest/m1/auth/narrowing.md). - Relay: each such request attaches its own lease through the `Client::attach` path [Relay tokens](/quest/m1/auth/relay-refresh.md) builds, once per request, with no sharing across requests carrying the diff --git a/quest/m1/bbr-ack-cleanup.md b/quest/m1/bbr-ack-cleanup.md new file mode 100644 index 0000000000..02ad06e0f1 --- /dev/null +++ b/quest/m1/bbr-ack-cleanup.md @@ -0,0 +1,61 @@ +# [M] Make BBR ACK cleanup scale with completed packets + +## Goal + +Large BBR flights do not make every ACK scan all outstanding packets. +Packet bookkeeping scales with acknowledged, lost, and expired entries while +preserving bandwidth samples and congestion behavior. Ship the improvement +through MoQ's published dependency chain without changing public APIs or wire +behavior. + +## Plan + +The fix lives in moq-dev/noq. In released 1.3.1 (`ff9d2ab5`), +[`on_end_acks`](https://github.com/moq-dev/noq/blob/ff9d2ab518cfb155f9ebb9925f1c784665eac92a/noq-proto/src/congestion/bbr3/mod.rs#L1783) +runs `retain` over all tracked packets, then scans them again to mark stale +entries. Draining a flight with fixed-size ACK batches has quadratic total +cleanup work. [Google QUICHE](https://github.com/google/quiche/blob/c961965aa3ee8f2b6f05ebcac794f7854101adcd/quiche/quic/core/congestion_control/bandwidth_sampler.cc#L377) +uses packet-number lookup and obsolete-prefix reclamation; use that as a +reference without copying a TCP or single-space assumption into QUIC. + +Retain an optimized benchmark sweeping 1,000, 4,000, and 16,000 outstanding +1200-byte packets against ACK batches of 1, 8, and 32. Time the controller +callbacks, excluding fixture setup and the simulator's own queue searches. +The initial local median-of-three measurements at eight packets per ACK were +0.650, 11.310, and 162.873 ms respectively. These are workload evidence, not +portable timing thresholds or a network-throughput comparison with Google. +Add steady-flight cases so draining the table cannot hide recurring costs. + +Replace whole-flight cleanup with indexed packet state and incremental +reclamation. Let the implementation choose the simplest representation that +handles sparse packet numbers, reordering, and separate Initial, Handshake, +and Data spaces. Bound retained state without repeatedly sweeping live +entries or scanning large unused packet-number gaps. Keep the existing +sampling and congestion policies unchanged; loss-sample repair and ECN +response belong to their own quests. + +Extend existing tests for ordered, reordered, duplicate, and batched ACKs; +loss and spurious loss; expiry; overlapping packet numbers in different +spaces; and handshake-space discard. Compare samples and control outputs +against the unchanged baseline on valid callback traces, allowing only the +bookkeeping change. A late event for expired state must remain safe and must +not reuse another packet's metadata. + +Acceptance is the removal of the whole-flight factor from per-ACK work, +with measured scaling and bounded retained memory under reordering and loss. +Report CPU time and memory across both benchmark axes, including small +flights, and explain any remaining logarithmic or amortized cost. Keep the +regressions in fork CI and the benchmark matrix at least nightly. Do not +substitute one hardware-specific millisecond limit for the scaling check. + +Land the fix in the fork, offer it upstream or record why not, publish an +immutable fork release, and pin the corrected dependency chain here before +completing this quest. Do not wait for the broader QUIC stack release or the +ECN fix. Update internal packet-lifetime comments inline; no new user guide +is needed. + +## Related + +- [Classic ECN](/quest/m1/bbr-classic-ecn.md) - an independent correctness fix in the same controller; coordinate ownership of the shared file +- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - preserve packet metadata needed by the separate loss-sample repair +- [Benchmark comparisons](/quest/m1/performance-comparisons.md) - reusable measurement guidance, not a prerequisite for this fix diff --git a/quest/m1/bbr-classic-ecn.md b/quest/m1/bbr-classic-ecn.md new file mode 100644 index 0000000000..d085ba2d8c --- /dev/null +++ b/quest/m1/bbr-classic-ecn.md @@ -0,0 +1,61 @@ +# [M] Make BBR respond to classic ECN + +## Goal + +BBRv3 responds to validated CE marks before a marking bottleneck has to drop +packets, including during Startup and ProbeUp. Keep ECT(0) enabled and ship +the corrected controller through MoQ's published dependency chain. L4S, +new configuration flags, and changing the default controller are out of scope. + +## Plan + +The fix lives in moq-dev/noq. In released 1.3.1 (`ff9d2ab5`), +[`on_congestion_event`](https://github.com/moq-dev/noq/blob/ff9d2ab518cfb155f9ebb9925f1c784665eac92a/noq-proto/src/congestion/bbr3/mod.rs#L1828) +passes CE to the lost-packet path with zero lost bytes. Startup and ProbeUp +skip the short-term loss response, while their high-loss checks see no lost +bytes. A controller reproduction sends eight rounds of 1, 2, 4, ..., 128 +1200-byte packets, 20 ms apart, acknowledging each round after 10 ms and +reporting CE after `on_end_acks`, as the transport does. Both marked and +unmarked controls remain in Startup with a 318,000-byte window and +42,167,347.2 bytes/s pacing. This is a controller result, not an AQM network +measurement. + +[Draft-06 section 3.7](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-3.7) +requires an ECN-capable sender to treat CE as congestion, without prescribing +one BBR response. [Google Linux BBRv3](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L1048) +has a separate Startup ECN response when its ECN mode is eligible. Choose a +classic response consistent with QUIC recovery: stop acceleration and reduce +the permitted sending load on new CE feedback, at most once per recovery +period. Do not fabricate lost bytes or apply repeated reductions for old CE +counts. Preserve the distinction between loss and CE, including undo of +spurious loss. Prefer the existing controller boundary; this fix does not +need a CE-fraction API or Google's L4S policy. + +Extend the existing shared BBR `Sim` with the failing reproduction, then test +through actual QUIC ECN validation and controller callbacks. Cover Startup, +ProbeUp, Cruise, ProbeRTT, repeated ACKs with no new CE, several CE-bearing +ACKs in one recovery period, a later period with new CE, simultaneous loss, +and invalid or missing ECN feedback. Acceptance requires a bounded decrease +in sending load under sustained CE, recovery after marking stops, and no +response on the unmarked control; merely changing an internal state is not +enough. Keep the default CUBIC path working. + +Retain a reproducible rate-limited marking-versus-dropping network case, +recording queue delay, goodput, loss, and response timing. Run deterministic +regressions in fork CI and the network case at least nightly. Provider +availability does not block this lab validation. Record the response rule +and its recovery-period boundary with the results. + +Land the fix in the fork, offer it upstream or record why not, publish an +immutable fork release, and pin the corrected dependency chain here before +completing this quest. Do not wait for the broader QUIC stack release or the +ACK cleanup fix. No wire change or new public API is intended; document any +necessary API change and apply the repository's branch rules. Update stale +controller and relay ECN documentation inline; no separate guide is needed. + +## Related + +- [ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - an independent fix in the same controller; coordinate ownership of the shared file +- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - the separate lost-packet sample repair +- [ECN measurement](/quest/m1/quic/ecn-measure.md) - measures the released response on the backbone +- [L4S](/quest/m2/quic-ecn.md) - a separate scalable ECN policy and opt-in configuration diff --git a/quest/m1/bbr-idle-burst.md b/quest/m1/bbr-idle-burst.md new file mode 100644 index 0000000000..480cbad830 --- /dev/null +++ b/quest/m1/bbr-idle-burst.md @@ -0,0 +1,31 @@ +# [S] BBR idle burst + +## Goal + +After a long keep-alive-only idle, a BBRv3 (`delay`) sender paces its next +burst near the bandwidth it learned before, not at one packet per RTT. A +regression test proves it. + +## Plan + +- The likely fix already shipped: moq-dev/noq#5 tells the controller of + starvation before the next send, released in moq-noq 1.3.1 and pinned by + #4206. The reporter ran 1.3.0. Nobody has reproduced the stall on either. +- In the fork, add a virtual-time transport test: learn the bandwidth, run + keep-alive only for about five minutes, send 250 KB, and assert the pacing + rate stays at or above about 0.9x the earlier max bandwidth. It must fail on + 1.3.0 and pass on 1.3.1. The existing label tests do not check the rate. +- If 1.3.1 still stalls, find the remaining cause (a stale `bw_shortterm` or a + ProbeRTT effect after idle) and fix it in the fork. Dropping the estimate + after long idle is a policy change for the m2 study, not this quest. +- The `iroh` feature uses upstream noq, which lacks the fix; offering it there + belongs to the upstream quest. + +## Closes + +- [#4219](https://github.com/moq-dev/moq/issues/4219) - the first send after an idle period is paced at a trickle + +## Related + +- [Upstream the fork](/quest/m1/quic/upstream.md) - offer the starvation fix to n0-computer/noq +- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - broader media measurements diff --git a/quest/m1/bench-relay.md b/quest/m1/bench-relay.md index 1cd12ddfe9..68609da539 100644 --- a/quest/m1/bench-relay.md +++ b/quest/m1/bench-relay.md @@ -18,3 +18,9 @@ than the bench is worth, say so and stop. Reuse the scenario, the publisher and subscriber sweeps, and the delivery accounting from `rs/moq-net/benches/session.rs` so the two results line up, and the difference is the relay layer. + +Run the dash shape (`session_dash_*`) first. In the model bench, the production +point (34 peer nodes x 5 projects x 24 tracks) costs about 100 ms of one core +per 1 s stats tick, for the whole mesh. In production, moving that one session +off a relay saved 50-90% of a core on that relay alone (2026-09-25). The gap is +the relay layer or the transport, not the model. diff --git a/quest/m1/binding-surface.md b/quest/m1/binding-surface.md index 87e3937bba..e789e32710 100644 --- a/quest/m1/binding-surface.md +++ b/quest/m1/binding-surface.md @@ -1,29 +1,25 @@ -# [M] The bindings reach the new decode, route, and connection APIs +# [M] Audio decode delay in every binding ## Goal moq-ffi, libmoq, and every wrapper (Python, Go, Swift, Kotlin, Dart) expose -three surfaces that landed in Rust only: the audio decode delay -(`decode::Options::delay` and `Consumer::delay()`), where a route came from -(`Route::source()` and `origin::Consumer::local()`), and libmoq's connection -timing (`failover_delay_us`, `resolution_delay_us`) beside the WebSocket -fallback settings. moq.pro's Python sidecar reads route sources from here. +`decode::Options::delay` and `decode::Consumer::delay()` so applications can +configure and observe audio playout delay. ## Plan - One PR, each wrapper touched once, in its own idiom: durations as the language's duration type where the wrapper already uses one, handles over flat methods where a surface has more than one call. -- libmoq gets the decode delay and route source in `rs/libmoq` itself, with - tests, not only in the C docs; regenerate `moq.h`. The additions are - additive, so `cpp/obs/src` and `doc/bin/obs.md` change only if OBS uses them. +- Add the libmoq implementation and tests in `rs/libmoq`, then regenerate + `moq.h`. Keep the additions compatible with the published C ABI. - Update `doc/lib/{py,swift,kt,go,dart,c}` in the same PR. -- Test each surface in every wrapper that has tests. +- Test configuration and observed delay in every wrapper that has tests. ## Required -- Native decode delay (#3967) has merged +- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - lands the native decode delay API on `main`; #3967 merged into the questline only ## Related -- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes the flat methods this adds +- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes the binding namespaces diff --git a/quest/m1/broadcast-close/README.md b/quest/m1/broadcast-close/README.md deleted file mode 100644 index 65302ef805..0000000000 --- a/quest/m1/broadcast-close/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Broadcast close - -## Goal - -Ending a broadcast is one operation in every language: `close()`, a permanent -retraction. A closed broadcast is no longer announced to peers or discoverable -locally, serves no new tracks, and can never be announced again. Tracks already -in flight carry on and end with their own FIN or reset. Dropping the last -handle does the same, without a warning. - -The finished-versus-aborted distinction goes away: `broadcast::Producer::finish`, -`abort(err)`, and `Consumer::is_finished` are deprecated, then removed on `dev`. -A broadcast has no FIN on the wire, only ANNOUNCE_START and ANNOUNCE_END, so -whether it ended cleanly never survives a hop. - -## Plan - -Decided with the maintainer on #4007 (the #4002 fix): - -- `close(&self)` does what `finish()` does today minus the finished flag: - retract, stop local discovery, serve no new track names, fail a later - `announce` with `Closed`. -- Dropping a broadcast only retracts it. The "dropped without finish()" warning - in `broadcast.rs`'s `Drop for Alive` goes, since drop is now a normal end. -- Garbage-collected bindings get an explicit `close()` rather than relying on - finalizers. - -Every change here behaves the same through a local consumer and a remote one; -only latency differs. Each child tests its scenario both in-process and over -an in-memory mock session, like `rs/moq-net/tests/finished_broadcast_mock.rs`. - -`broadcast::Consumer` keeps its close signal, documented as "this broadcast -object ended", not "the path went offline": announcements say whether a path -is live, and a path can be announced again by a new object. Caches and moq-hls -use it to tell a live object from a replaced one at the same path. - -Stage it as the children below: Rust first, then the bindings, then the `dev` -removal. - -## Quests - -- [Rust close](/quest/m1/broadcast-close/rust.md) - moq-net gains `close()`, deprecates `finish`/`abort`/`is_finished`, and every Rust and JS caller moves over -- [Binding close](/quest/m1/broadcast-close/bindings.md) - moq-ffi, libmoq, and every wrapper expose `close()` and deprecate `finish` -- [Kotlin end](/quest/m1/broadcast-close/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name -- [Remove finish](/quest/m1/broadcast-close/remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - -## Related - -- [#4002](https://github.com/moq-dev/moq/issues/4002) - broadcast end aborted in-flight tracks; fixed by #4007, which made ending a front retract without touching its tracks diff --git a/quest/m1/broadcast-close/bindings.md b/quest/m1/broadcast-close/bindings.md deleted file mode 100644 index 86437ec9e4..0000000000 --- a/quest/m1/broadcast-close/bindings.md +++ /dev/null @@ -1,29 +0,0 @@ -# [M] Binding close - -## Goal - -Every binding ends a broadcast with `close()`, mirroring Rust, and its -`finish` is deprecated. - -## Plan - -- moq-ffi: add `MoqBroadcastProducer::close()` next to `finish()` in - `rs/moq-ffi/src/producer.rs`. It closes the broadcast, then the catalog, as - `finish` does, so a catalog error cannot stop the broadcast from ending. - Deprecate `finish`. -- libmoq: export `moq_publish_close` and deprecate `moq_publish_finish`. Move - `cpp/obs/src/moq-output.cpp` and the C tests over. -- Wrappers: `py/moq-rs` `BroadcastProducer.close()` (its `finish` docstring - wrongly says it closes the tracks), swift `Broadcast.close()`, and the Go - `Publish.Close()`. Kotlin and Dart only alias the generated type, so they - pick `close` up from moq-ffi. -- Move the binding tests over, keeping one test per binding that a second - `close` errors or no-ops, whichever Rust settles on. -- Update `doc/lib/{py,swift,kt,go,dart,c}`, including `doc/lib/go/index.md`'s - `broadcast.Finish()` sample. -- Fix the moq-ffi `origin.rs` doc comment that points users at a - `broadcast.closed()` the bindings don't have. - -## Required - -- [Rust close](/quest/m1/broadcast-close/rust.md) - the binding forwards to `broadcast::Producer::close` diff --git a/quest/m1/broadcast-close/rust.md b/quest/m1/broadcast-close/rust.md deleted file mode 100644 index b2f8ea9acc..0000000000 --- a/quest/m1/broadcast-close/rust.md +++ /dev/null @@ -1,49 +0,0 @@ -# [M] Rust close - -## Goal - -`moq_net::broadcast::Producer::close()` is the one way to end a broadcast, and -no Rust or JS code in the repository calls a deprecated end API. - -## Plan - -In `rs/moq-net/src/model/broadcast.rs`: - -- Add `close(&self)`: set `closing`, close the liveness token, and retire the - announcer, as `finish` does today without setting `finished`. Borrow, like - `finish`: any clone ends it. -- Once a broadcast has ended, by `close()` or its last producer dropping, every - new lookup on any consumer answers `Unroutable`: `Consumer::track`, and the - requests still pending for names nothing served. That is what a fresh - `request_broadcast` for the path answers, so a consumer can't tell a raw - handle from one reached through an origin, or a local source from a remote - one. Today the same lookup answers `NotFound` while clones live and `Dropped` - after. Tracks already read are untouched and end on their own FIN or reset. - This changes the error a published API returns; say so in the PR. -- Document `broadcast::Producer::consume()` as a view of this one publisher: a - new publisher at the path is never spliced into it, so a consumer that should - not care about its source goes through an origin. -- Mark `finish`, `abort`, and `Consumer::is_finished` `#[deprecated]` and - `#[doc(hidden)]` per `rs/CLAUDE.md`. `finish` forwards to `close`. Leave - `abort`'s behavior alone until the `dev` removal. -- Drop the "dropped without finish()" warning in `Drop for Alive`. -- `SourceGuard` (the lite and IETF subscribers' source handle) closes on both - a graceful end and a drop, since the abort it records on drop reaches no one - past this hop. Check the IETF `Detach::Abrupt` path still ends the source. - -Callers to move (production): `model/origin.rs` (front end), -`lite/subscriber.rs` (`Route::finish`), `ietf/subscriber.rs`, moq-srt -`ts.rs`, moq-rtmp `server.rs` and `dial.rs`, moq-stats `produce.rs`, moq-gst -`sink/imp.rs`, moq-rtc `server/mod.rs`, moq-transcode `lib.rs`, moq-relay -`cluster.rs` (gossip registration), moq-boy `main.rs`, and moq-tokio's -`clock` and `chat` examples. Tests and benches across moq-net, moq-tokio, -moq-hls, moq-bench, and moq-gst move too. - -moq-srt and moq-rtmp `Publisher::abort` drop the broadcast instead of ending -it; closing it explicitly makes that path deterministic. - -In `js/net/src/broadcast.ts`, `Producer.close()` and `Consumer.close()` stop -taking `abort`: deprecate the parameter in the JSDoc and move the two origin -teardown callers in `origin.ts`. JS `close()` already blocks re-announcing. - -Update `doc/lib/rs/moq-net.md`, which says the route retracts on `finish()`. diff --git a/quest/m1/broadcast-close/remove.md b/quest/m1/broadcast-remove.md similarity index 73% rename from quest/m1/broadcast-close/remove.md rename to quest/m1/broadcast-remove.md index 4b62cc1db5..fd8898f0ae 100644 --- a/quest/m1/broadcast-close/remove.md +++ b/quest/m1/broadcast-remove.md @@ -18,5 +18,4 @@ This is a published API break, so it targets `dev`. ## Required -- [Binding close](/quest/m1/broadcast-close/bindings.md) - every binding already has `close()` to move to -- [Kotlin end](/quest/m1/broadcast-close/kotlin-end.md) - Kotlin keeps a forced end once `finish` is gone +- [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin keeps a forced end once `finish` is gone diff --git a/quest/m1/close-codes.md b/quest/m1/close-codes.md new file mode 100644 index 0000000000..f1e3e9a34f --- /dev/null +++ b/quest/m1/close-codes.md @@ -0,0 +1,39 @@ +# [M] Close codes on every transport + +## Goal + +A client sees the application close code its peer sent, for example +`SessionError::App(4011)` from `Session::abort` or `Request::reject`, over +WebSocket (qmux) and raw QUIC (`moqt://`), as it already does over +WebTransport. Never `Transport("connection closed")` or its own `Internal`. + +## Plan + +Both bugs are upstream; fix them at the source, release, and bump the pins. + +- qmux 0.5.1 (`moq-dev/web-transport`) lets later writes overwrite the + recorded close in `session.rs`: the WS Close frame read after + APPLICATION_CLOSE (reader loop, backend `send_replace`), a local `close()` + after the peer closed, and a second peer APPLICATION_CLOSE. `accept_uni` and + `accept_bi` also return a bare `Closed`. Make the first close win, make + `close()` a no-op once closed, and have `accept_*` return the recorded + reason. Add a qmux test where APPLICATION_CLOSE and EOF arrive together. +- `web-transport-moq` 1.3.1 (`moq-dev/noq`) maps `ApplicationClosed` only + through the HTTP/3 code space in `error.rs`, so a raw `moqt://` code yields + no `session_error()`. Map raw QUIC codes directly. +- moq-net's `close(Internal)` after a transport error is correct: closing a + closed connection does nothing. Do not work around it here. +- One moq-tokio regression runs the issue's three cases (abort after accept, + abort then drop, reject during handshake) over `https://`, `ws://`, and + `moqt://`, and fails on the current pins. +- Check whether `@moq/net`'s qmux peer keeps the first close too; fix it in + the same PR if not. + +## Closes + +- [#4249](https://github.com/moq-dev/moq/issues/4249) - application close code is lost over the WebSocket (qmux) transport + +## Related + +- [qmux on noq](/quest/m1/quic/qmux.md) - the rewrite must keep first-close-wins +- [io_uring close](/quest/m1/quic/uring-close.md) - the same symptom class on the io_uring backend diff --git a/quest/m1/ffi-shape/json.md b/quest/m1/ffi-shape/json.md index 191b3fa2c6..0ff6f35227 100644 --- a/quest/m1/ffi-shape/json.md +++ b/quest/m1/ffi-shape/json.md @@ -12,7 +12,7 @@ sets is what the other children copy. JSON is the smallest group, so it carries the setup cost: a Python submodule, a Go subpackage, a Kotlin package, a Dart library, and a Swift namespace, each wired into its package build, tests, and docs. Settle the pattern here and -write it down where the next child will find it (`rs/moq-ffi/CLAUDE.md` if it +write it down where the next child will find it (`rs/moq-ffi/AGENTS.md` if it is a convention, after reading `PROMPTING.md`). Mirror `moq-json`'s names (`snapshot`, `stream`) and constructors, which diff --git a/quest/m1/import-discontinuity.md b/quest/m1/import-discontinuity.md deleted file mode 100644 index c5c2fd6025..0000000000 --- a/quest/m1/import-discontinuity.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Import discontinuity - -## Goal - -A publisher that seeks or pauses tells its importer so, and the flush jitter -measurement restarts instead of counting the break. `moqsink` does it on a -seek, and C and FFI publishers can do it too. - -## Plan - -- `moq_mux::container::Producer::discontinuity()` already resets the flush - baseline (`catalog/estimate.rs`). `import::Track` gains a `discontinuity()` - that forwards to it, and the codec importers without one gain it too. Only - the baseline resets; advertised values are never lowered. -- `moqsink` calls it when a pad re-anchors after a flush or a new segment - (`rs/moq-gst/src/sink/pad.rs`). Test that a seek on an `encoder` pad does - not raise the advertised jitter. -- Expose it as `moq_publish_media_discontinuity` in libmoq and as - `discontinuity()` in moq-ffi and its hand-written wrappers, per the - Cross-Package Sync table. This is additive, so it lands on `main`. diff --git a/quest/m1/js-bare-fin.md b/quest/m1/js-bare-fin.md deleted file mode 100644 index 212b0cdb53..0000000000 --- a/quest/m1/js-bare-fin.md +++ /dev/null @@ -1,18 +0,0 @@ -# [S] JS bare FIN - -## Goal - -A `@moq/net` subscriber aborts a track whose subscribe stream FINs before the -publisher declared its end, over moq-lite and IETF, as Rust does since #4083. -A bare FIN is a failed request, never a clean end. - -## Plan - -- The drafts agree: draft-19 section 3.3.2 treats a FIN before the required - messages (PUBLISH_DONE) as a failure, and the moq-lite draft has a publisher - FIN only after SUBSCRIBE_END. -- Abort with the error Rust uses, so both languages report the same thing. Add - an interop case where a publisher FINs without declaring an end, in both - directions. - -The clean-end path this tightens landed in #4086. diff --git a/quest/m1/js-group-guard.md b/quest/m1/js-group-guard.md new file mode 100644 index 0000000000..eca7b3effb --- /dev/null +++ b/quest/m1/js-group-guard.md @@ -0,0 +1,24 @@ +# [XS] JS group guard + +## Goal + +A `@moq/net` publisher serving a group past the subscription's max age +abandons it without an unhandled rejection, over moq-lite and IETF. Node no +longer crashes on "group exceeded the subscription max age budget". + +## Plan + +- `#guard` in `js/net/src/group.ts` rejects early without attaching a handler + to the write it was handed, which the lite and IETF publishers have already + started. +- Pass a thunk (`() => stream.write(...)`) and check expiry before calling it, + as Rust's `poll_expired` does in `rs/moq-net/src/lite/publisher.rs`. This + also skips writing into a group about to be abandoned. A bare + `operation.catch(() => {})` was rejected: it still starts the write. +- `guardGroup` is internal; no public API change. +- Regression: serve an already-stale group and assert no `unhandledRejection` + fires. + +## Closes + +- [#4247](https://github.com/moq-dev/moq/issues/4247) - Group.Consumer max-age guard leaves the guarded write without a handler diff --git a/quest/m1/js-origin-scope.md b/quest/m1/js-origin-scope.md deleted file mode 100644 index 9bc8530c60..0000000000 --- a/quest/m1/js-origin-scope.md +++ /dev/null @@ -1,27 +0,0 @@ -# [M] JS origin scope drives announce interest - -## Goal - -A `@moq/net` origin scoped to a prefix asks the peer only for that prefix. -An origin that was never scoped still asks for every namespace. A relay that -ignores an empty `SUBSCRIBE_NAMESPACE` then delivers a namespace the caller -actually named. - -## Plan - -`Producer.scope(root, patterns)` matches Rust `origin::Producer::scope`: the -root is stripped from paths, and the patterns are what the handle may publish -and subscribe under. `forwardAnnounced` sends one `SUBSCRIBE_NAMESPACE`, and -the moq-lite equivalent, per literal head of those patterns, the same rule as -Rust `interest_prefixes`. Hidden routes stay opted in on those subscriptions. - -An unscoped origin still allows `**`. Its head is the empty prefix, so the -session sends one subscription for everything, which is today's behavior. - -`doc/concept/moq-lite.md` says the prefixes a session asks for are the -origin's scope. No new page. - -## Related - -- [JS caught up](/quest/m1/js-announce-caught-up.md) - when the replay of those prefixes has landed -- [IETF announce count](/quest/m1/ietf-announce-count.md) - how an IETF consumer knows that replay is finished diff --git a/quest/m1/js-timeline-scans.md b/quest/m1/js-timeline-scans.md new file mode 100644 index 0000000000..b46d4c16c7 --- /dev/null +++ b/quest/m1/js-timeline-scans.md @@ -0,0 +1,33 @@ +# [M] JS timeline scans + +## Goal + +A `@moq/net` publisher's per-group cost stays flat as the retained window +grows. `Subscriber.#drift()`, `#reach()`, and the prune pass stop scanning +every cached group. + +## Plan + +- `js/net/src/track.ts` keeps the timeline in a `Map` of every cached group, + consumed ones included. `#drift` and `#reach` scan it per popped group and on + every guard re-evaluation, and `#prune`/`#schedulePrune` scan it per publish. +- Mirror Rust's `rs/moq-net/src/model/track.rs`: keep sequences sorted (append + fast path, binary insert otherwise). Drift walks backward to the first + stamped, non-errored group in range; reach binary-searches the successor. + Fold the prune scans in if the benchmark shows them. Memoizing per revision + was rejected: a new group still costs O(n) per subscriber. An incremental + edge was rejected: invalidation on aborts and cursor ends gets fiddly. +- Add `js/net/bench/track.ts`: microseconds per published group through + Producer, Subscriber, `tryRecvGroup`, and the guard, with no transport. Sweep + retained groups (about 25 to 1500) against subscribers (1 to 16), plus a case + with guards in flight. Wire it into `nightly.yml` beside `broadcasts.ts`. The + fix passes when the retained-groups axis is flat. + +## Closes + +- [#4246](https://github.com/moq-dev/moq/issues/4246) - per-group publishing cost grows with the retained window + +## Related + +- [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the browser media path; this bench covers the track model +- [JS group guard](/quest/m1/js-group-guard.md) - the thunk there cuts guard calls, not the scan cost diff --git a/quest/m1/broadcast-close/kotlin-end.md b/quest/m1/kotlin-end.md similarity index 81% rename from quest/m1/broadcast-close/kotlin-end.md rename to quest/m1/kotlin-end.md index 4ecb51e805..44516dd62c 100644 --- a/quest/m1/broadcast-close/kotlin-end.md +++ b/quest/m1/kotlin-end.md @@ -12,7 +12,3 @@ generated `close()` would collide with `AutoCloseable.close()`. binding keeps `close()`. Kotlin's `close()` still only releases the handle. - Test that `end()` ends the broadcast while a `dynamic()` handle is still held. Document it in `doc/lib/kt`. - -## Required - -- [Binding close](/quest/m1/broadcast-close/bindings.md) - moq-ffi gains the `close()` this renames diff --git a/quest/m1/moq-c.md b/quest/m1/moq-c.md index 608a5bfcb7..ba18262125 100644 --- a/quest/m1/moq-c.md +++ b/quest/m1/moq-c.md @@ -19,7 +19,7 @@ release that points users at `moq-c`. tooling (release-plz, alert and nightly workflows, cachix) must follow the new name and tag; check every workflow that names libmoq. - Update every consumer and reference: `cpp/obs` (`find_package`), interop - clients, `doc/lib/c`, `doc/bin/obs.md`, the root `CLAUDE.md` Cross-Package + clients, `doc/lib/c`, `doc/bin/obs.md`, the root `AGENTS.md` Cross-Package Sync table, and quests that name libmoq. Grep the whole repository. - Publish a final `libmoq` release whose README and description point at `moq-c`, then stop publishing it. The maintainer cuts releases; the PR only diff --git a/quest/m1/opus-conceal.md b/quest/m1/opus-conceal.md new file mode 100644 index 0000000000..507d45a24c --- /dev/null +++ b/quest/m1/opus-conceal.md @@ -0,0 +1,29 @@ +# [XS] Opus concealment length + +## Goal + +`moq_audio` `Decoder::decode(&[])` conceals one Opus packet, the length of the +last real one, instead of 120 ms. The API is unchanged and lands on main. + +## Plan + +- `rs/moq-audio/src/decode/decoder.rs` passes `max_frame_size` (120 ms) as + libopus's `frame_size` on every call, and libopus conceals exactly that many + samples. Record the last packet's sample count (`opus_packet_get_nb_samples`) + and pass it for an empty packet; it is always a multiple of 2.5 ms. +- Refuse loss before any packet has decoded, rather than surfacing libopus's + `BUFFER_TOO_SMALL`. +- Document on `decode` how much an empty packet conceals. +- Regression from the issue's repro: one lost 20 ms packet yields 20 ms. +- A `conceal(duration)` method and `decode(Option<&[u8]>)` were rejected: no + consumer needs a caller-chosen length, and the latter breaks the common case. + How platform backends signal loss belongs to the decode-backend seam. + +## Closes + +- [#4248](https://github.com/moq-dev/moq/issues/4248) - `Decoder::decode(&[])` conceals 120 ms of Opus audio for one lost packet + +## Related + +- [Audio loss recovery](/quest/m2/audio-loss-recovery.md) - the FEC versus concealment policy study +- [Decode backend](/quest/m1/audio-codecs/decode-backend.md) - where platform decoders signal loss diff --git a/quest/m1/origin-mount.md b/quest/m1/origin-mount.md index a2f5b667c7..efc512d6b8 100644 --- a/quest/m1/origin-mount.md +++ b/quest/m1/origin-mount.md @@ -67,5 +67,5 @@ source changed rather than scanning every mount or session. ## Related -- [Origin narrowing](/quest/m1/origin-narrowing.md) - the same origin/auth area; land them in sequence +- [Origin narrowing](/quest/m1/auth/narrowing.md) - the same origin/auth area; land them in sequence - `Cluster::admit` (#3943) - should carry mounts too diff --git a/quest/m1/origin-narrowing.md b/quest/m1/origin-narrowing.md deleted file mode 100644 index 70deef9539..0000000000 --- a/quest/m1/origin-narrowing.md +++ /dev/null @@ -1,72 +0,0 @@ -# [L] A live origin grant narrows in place - -## Goal - -A `moq-net` origin handle's grant can be replaced by a narrower pattern union -while the session is live: announce cursors retract the patterns that fell -outside, resolution refuses them, and broadcasts already handed out under -them end rather than draining. The relay uses it on revalidation so a -moderation decision (a deafened user's audio path) takes effect on the -session it targets, which a forked client cannot bypass. This is the -per-subscriber exclusion [#2714](https://github.com/moq-dev/moq/issues/2714) -asked for; the scope and match half already landed in #3672 and #3746. - -A narrowing always succeeds. Until it can end what it removes, the relay keeps -closing the session on a narrowed grant: that is simple, secure, and correct. - -## Plan - -- Narrowing and handout revocation ship together. #3971 built the narrowing - alone and was closed: it had to refuse whenever anything live sat in the - removed part (the session's own reads, another session's reads of the same - front, or its own publishes), which is exactly when a grant narrows, so it - only saved a reconnect for access nobody was using. -- `origin::Producer` and `origin::Consumer` gain one narrowing operation on - the handle, shared by every handle a session derived from it. A grant that - is not a subset of the current one is refused; widening is never a - narrowing. If handles need a tree to share it, keep the ceiling on the grant - node itself (an `Arc` shared by clones and children), not in a side map on - the origin. -- Revocation needs handouts attributed to the grant that requested them. - Decide where it is enforced first: - - **Model, per subscription.** A handout shares its state with the front - every other session reads, so ending one session's copy needs state per - handout reaching the track, subscription, and group readers. The cheapest - shape found rides the group expiry check (`GroupExpiry` in - `rs/moq-net/src/model/track.rs`), which runs only on a blocked read and - parks on the subscription's own state, so a private revoked signal wakes - parked readers for free. It still needs a registry per handout (a push per - subscribe on every relay session), a reason so readers see `Unauthorized` - rather than `Old`, and checks on ordered reads, datagrams, fetch, and peek. - The group in flight ends too: a group may stay open as long as its track, - so its boundary is no boundary at all. A transport that never learns about - the change still cannot keep reading. - - **Session.** The `moq-net` publisher watches one narrowing signal per - session and resets the subscriptions outside the grant. The cost is per - session, not per frame, but a transport reading the model directly is not - bound by it. -- Decide the publish side: whether a route or broadcast the session published - outside the narrowed grant is retracted or aborted. -- Relay revalidation uses it: a re-checked grant with the same root narrows - the session's origin handles instead of closing the session - (`Lease::ended` reporting `Reason::Narrowed` in `rs/moq-relay/src/auth.rs`), - on every accept path (native, WebSocket, io_uring, `moq --listen`). A - changed root still closes. -- Prove the deafen case at the model layer: subscribe under a room prefix, - narrow with a grant that excludes that audio path, and assert the existing - subscription closes with `Unauthorized` and no further objects arrive, - while a sibling path under the same prefix keeps flowing. -- Benchmark the per-subscribe and routing cost in - `rs/moq-net/benches/origin.rs`, swept over routes and subscribers, with an - origin that never narrows as the baseline. - -Public API: additive on moq-net (one method) and on moq-relay's revalidate -path. Wire: none. - -## Closes - -- [#2714](https://github.com/moq-dev/moq/issues/2714) - close this issue when the quest finishes - -## Related - -- [In-band auth](/quest/m1/auth/README.md) - the token union that a narrowing later revalidates diff --git a/quest/m1/quic/bbr-loss-parity.md b/quest/m1/quic/bbr-loss-parity.md index 2bcee6f51f..647d1123a5 100644 --- a/quest/m1/quic/bbr-loss-parity.md +++ b/quest/m1/quic/bbr-loss-parity.md @@ -9,7 +9,7 @@ citations point at draft-06. ## Plan -Two gaps remain after the seven correctness fixes, both in +The lost-packet sample gap remains after the seven correctness fixes in `noq-proto/src/congestion/bbr3/mod.rs` in moq-dev/noq: - Loss handling reuses the shared ACK sample (`self.rs`) as scratch space, @@ -17,18 +17,18 @@ Two gaps remain after the seven correctness fixes, both in sample exists. [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c)'s `bbr_handle_lost_packet` builds a local sample from the lost packet instead. Do the same so a loss never reads or clobbers ACK state. -- Undo restores ProbeUp directly; - [draft-06 section 5.5.11](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.5.11) - goes back through Refill. + +Release 1.3.1 already restores a spurious ProbeUp exit through Refill, as +[draft-06 section 5.5.11](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.5.11) +requires. Preserve that behavior and its regression. While in the file, move the remaining draft-05 links and renamed pseudocode identifiers (such as `probe_up_acked_per_inc`) to draft-06; comment-only. -This stacks on the seven fixes' noq branches until they merge, and does not -gate [the BBR release](/quest/m1/quic/bbr-release.md). Add a failing -regression for each behavior on the shared test `Sim`: a loss before the -first ACK sample, a loss between ACKs that must not alter the next ACK's -sample, and a spurious-loss undo that passes through Refill. Internal only; +Start from released 1.3.1 or newer, without waiting for the broader QUIC stack +release. Add failing regressions on the shared test `Sim`: a loss before the +first ACK sample, and a loss between ACKs that must not alter the next ACK's +sample. Retain the spurious-loss undo coverage through Refill. Internal only; no `Controller` or wire change. ## Related diff --git a/quest/m1/quic/ecn-measure.md b/quest/m1/quic/ecn-measure.md index 1865cabd3a..56b6658477 100644 --- a/quest/m1/quic/ecn-measure.md +++ b/quest/m1/quic/ecn-measure.md @@ -14,6 +14,11 @@ A manual procedure on Linux, run as root, with the commands and what to record written here so the dualpi2 run and any later provider re-check repeat it. +Use the released [classic BBR ECN fix](/quest/m1/bbr-classic-ecn.md) for the +controller-response verdict and record the exact dependency version. Provider +mark-survival captures alone do not establish a controller response; a result +from 1.3.1 is a defective baseline, not evidence that classic ECN cannot help. + - Bottleneck: two relays on the io_uring runtime across a netem link with a rate limit and delay, once with `fq_codel` marking (`ecn` on) and once with the same qdisc dropping (`noecn`). Measure queueing delay at the @@ -22,11 +27,15 @@ repeat it. goodput. - Providers: between a Linode relay and an OVH relay, `tcpdump -v` on each end shows the TOS byte of arriving packets. Record whether ECT(0) arrives - intact in both directions and whether any CE appears; ECN is only alive - when both directions preserve the marks, since a stripped return path - means ACKs without counts. Repeat the capture for cross-provider and - same-provider pairs. + intact in each data direction and whether any CE appears. Check that + the reverse ACKs report the received ECN counts; the ACK packets need not + themselves retain an ECN IP marking to carry those counts. Repeat the + capture for cross-provider and same-provider pairs. - Record the qdisc parameters, the moq-bench profile, the kernel version, and the tcpdump summaries beside the numbers in the L4S quest's Plan. If neither provider preserves the marks, say so there: L4S stays off and the marking response is only a lab result. + +## Required + +- [Classic BBR ECN](/quest/m1/bbr-classic-ecn.md) - the final response verdict needs the corrected released controller diff --git a/quest/m1/quic/qmux.md b/quest/m1/quic/qmux.md index fef128ac3a..c67682ead8 100644 --- a/quest/m1/quic/qmux.md +++ b/quest/m1/quic/qmux.md @@ -38,7 +38,9 @@ Add the missing wire evidence before release: golden draft-02 vectors, bidirectional interoperability against the published `qmux` 0.5.x crate, and the TypeScript qmux/WebSocket peer used by `js/net`. Preserve rejection of prohibited QUIC frames, params-first setup, record-size validation, close and -reset semantics, keep-alive behavior, and bounded flow-control tests. +reset semantics (the first recorded close wins, per +[close codes](/quest/m1/close-codes.md)), keep-alive behavior, and bounded +flow-control tests. The crate lives in the fork's workspace as `moq-noq-qmux`, so the stream state machine internals it drives stay crate-private there; `moq-dev/web-transport`'s diff --git a/quest/m1/relay-peer-set.md b/quest/m1/relay-peer-set.md deleted file mode 100644 index 14a2e46b24..0000000000 --- a/quest/m1/relay-peer-set.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] A wire consumer tells a client hop from a peer hop - -## Goal - -A sidecar reading a relay over the wire knows whether a route entered on that -relay or came from one of its cluster peers, and every way a peer is admitted -can say it is one. In-process consumers already read `Route::source()` and -`origin::Consumer::local()`; a sidecar sees `[.., x, relay]` and cannot tell a -client `x` from a peer `x`. moq.pro's Python sidecar answers it today with a -hop-id bit prefix (`agent/moq_agent/matching.py`). - -## Plan - -- The relay publishes its cluster peer set in its stats broadcast: the hops of - the peers whose routes it currently holds, which is exactly what makes a - delivered route a peer's. Derive it from the origin's peer-marked routes - rather than from session bookkeeping, so the wire and in-process answers - cannot disagree. Name the frame's shape in `doc/bin/relay/config.md` (stats - section) so the sidecar and the producer agree, and land it after the - FlatBuffers stats flavor settles the track layout. -- `moq auth serve` can mark a peer (`peer: true` in the grant): an mTLS grant - for the cluster CA, and a JWT whose claims say so. Today only dials the relay - makes, LAN peers, and a custom auth server's grant mark one, so a stock mesh - counts inbound JWT and mTLS peers as clients. - -Public API: additive on moq-stats and moq-auth. Wire: the stats broadcast gains -a peer-set frame; the JWT claims may gain a field. - -## Related - -- [Route cost](/quest/m1/route-cost.md) - the other route fact JS lacks diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md index 3a297810e6..39ad6edba2 100644 --- a/quest/m1/session-close.md +++ b/quest/m1/session-close.md @@ -22,7 +22,3 @@ end and returns a promise, which is a published break, so that change targets `doc/concept/moq-lite.md` says a graceful close withdraws announces and an abort does not. No new page. - -## Related - -- [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` on a broadcast, which is not this session end diff --git a/quest/m1/tooling/justfiles.md b/quest/m1/tooling/justfiles.md index cae79df4d1..22597e1461 100644 --- a/quest/m1/tooling/justfiles.md +++ b/quest/m1/tooling/justfiles.md @@ -87,7 +87,7 @@ wanted), `_shell`, `_flake` as its one-liner, `clean`, the rs `package` and and `preset` bodies. Docs: `doc/setup/dev.md`, `CONTRIBUTING.md`, `test/README.md`, and the -`CLAUDE.md` mentions of `just wasm` follow the survivors. Verify with `just +`AGENTS.md` mentions of `just wasm` follow the survivors. Verify with `just check`, `just ci test`, and `just check --all`, and confirm every recipe name check.yml, cache.yml, nightly.yml, interop.yml, wasm.yml, obs.yml, swift.yml, and release-*.yml invoke still resolves. diff --git a/quest/m1/uring-tcp/stream.md b/quest/m1/uring-tcp/stream.md index 9aa3076b53..780f6d9c32 100644 --- a/quest/m1/uring-tcp/stream.md +++ b/quest/m1/uring-tcp/stream.md @@ -23,7 +23,7 @@ local task set, so a connection never leaves the thread that accepted it. hyper becomes a first direct dependency. Today it reaches the workspace only through `axum` and `axum-server`; add it to `[workspace.dependencies]` at the -newest stable version, per `rs/CLAUDE.md`. +newest stable version, per `rs/AGENTS.md`. Cover it the way the UDP path is covered: tests that run against whichever backend is compiled, an HTTP round trip through hyper on the worker, and a diff --git a/quest/m2/README.md b/quest/m2/README.md index d80049439f..80fa09dd1b 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -53,6 +53,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [Compressed tracks](/quest/m2/flate/README.md) - any track compresses per group from every language, not only the JSON modes - [Binary delta stats](/quest/m2/stats-delta.md) - an on-demand varint delta flavor of every stats track, if relay encode CPU still matters after the JSON fixes - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation +- [Plan: routing without a hop list](/quest/m2/plan-routing-origin.md) - whether announcements can drop the hop list and stay loop-free once stitching keys on the subscribe reply - [Redundant ingest](/quest/m2/redundant-ingest.md) - decide whether two publishers sharing one epoch may splice, and who declares the incumbent dead before the keep-alive does - [Multipath spike](/quest/m2/multipath-spike.md) - whether bonded contribution over multipath QUIC is worth building, given it needs noq on both ends - [Receive timestamps](/quest/m2/quic-receive-ts.md) - per-packet arrival times in ACKs, the feedback GCC and deadlines need diff --git a/quest/m2/mobile-ownership.md b/quest/m2/mobile-ownership.md index 79b115d25e..14e17f4c94 100644 --- a/quest/m2/mobile-ownership.md +++ b/quest/m2/mobile-ownership.md @@ -18,7 +18,7 @@ capture quests in this questline start only once this is settled. Kotlin over `moq-ffi`, what the iroh-live reimplementation says about the Rust-native audience, and the copy cost of byte-array frames measured on a device. -- Record the verdict here and in `rs/moq-ffi/CLAUDE.md`, and re-estimate +- Record the verdict here and in `rs/moq-ffi/AGENTS.md`, and re-estimate [Android capture](/quest/m2/mobile-capture-android.md) and [iOS capture](/quest/m2/mobile-capture-ios.md) against it; both target `moq-video`, so replace them with the required platform-owned implementation diff --git a/quest/m2/plan-routing-origin.md b/quest/m2/plan-routing-origin.md new file mode 100644 index 0000000000..5a0c7f74f3 --- /dev/null +++ b/quest/m2/plan-routing-origin.md @@ -0,0 +1,41 @@ +# [M] Plan: routing without a hop list + +## Goal + +Decide whether moq-lite announcements can drop the full hop list and still +route loop-free, then write the decision into quests. Today every +announcement carries the Hop IDs it traversed. That path does three jobs: +loop prevention (a relay discards a path containing itself), request +exclusion (never serve a request back through the peer that made it), and +route identity (the first hop decides whether a failover may stitch +seamlessly). The hop list was designed before prefix claims, and once the Spread quest in +[Wildcard advertisements](/quest/m1/wildcard/README.md) moves stitching +identity onto the subscribe reply, only the first two jobs remain. + +## Plan + +Open questions, to settle with the maintainer: + +- Loop freedom without a path. Rising cost alone ("cost + 1, never + advertise lower") counts to infinity after a withdrawal, as two relays + offer each other the stale route at ever higher cost. Remembering each + route's immediate neighbor and never echoing a route back to it (split + horizon) stops two-relay loops, but not three-relay loops. It also hides + backups: a relay never learns an alternative that passes back through the + neighbor it came from. Babel's feasibility condition (RFC 8966: a + per-origin sequence number; a route is feasible when its sequence number is + newer, at any cost, or equal and cheaper than the best seen for it) is + loop-free with only the origin on the wire. Weigh it and + any simpler scheme against what the relay cluster actually needs. +- What replaces request exclusion, and whether it still matters once + announcements are loop-free. +- What the stats and sidecar consumers lose without a path, and whether + anything besides the origin must stay on the wire. +- The version it lands in (lite-07 is `moq-lite-07-wip` today) and the + bridge to versions that still carry a hop list. lite-07 already compresses + each announcement's hop chain against a live base (#4196, + `rs/moq-net/src/lite/compress.rs`); dropping the hop list removes that too. + +## Related + +- [Wildcard advertisements](/quest/m1/wildcard/README.md) - its Spread quest moves stitching identity to the subscribe reply, which this builds on diff --git a/quest/m2/quic-ecn.md b/quest/m2/quic-ecn.md index 61a423e7a3..e4b0e9d24e 100644 --- a/quest/m2/quic-ecn.md +++ b/quest/m2/quic-ecn.md @@ -9,10 +9,12 @@ marks fall back to no ECN, and a viewer's session is unaffected. ## Plan -Classic ECN is already end to end on both runtimes; this quest is the -fork-side half. noq-proto has no ECN knob: `sending_ecn` is hardcoded on -per path and only an ACK without counts turns it off, so both `off` and -`ect1` need the fork. +ECT(0) marking and ACK ECN counts are carried end to end, but BBR's +[classic CE response](/quest/m1/bbr-classic-ecn.md) must be corrected before +it is used as the baseline. This quest adds the scalable policy separately. +noq-proto has no ECN knob: `sending_ecn` starts on per path and validation +failure or an ACK without counts turns it off, so both `off` and `ect1` +need the fork. - In the fork: an `Ect1` marking option and the accounting to keep the two codepoints apart, so an L4S response (proportional to the CE fraction per @@ -33,5 +35,6 @@ per path and only an ACK without counts turns it off, so both `off` and ## Required +- [Classic BBR ECN](/quest/m1/bbr-classic-ecn.md) - establish a corrected released classic response before comparing L4S - [Measure ECN on the backbone](/quest/m1/quic/ecn-measure.md) - the provider verdict this quest acts on diff --git a/rs/CLAUDE.md b/rs/AGENTS.md similarity index 97% rename from rs/CLAUDE.md rename to rs/AGENTS.md index 2c9e8d74fc..d2ba893ae8 100644 --- a/rs/CLAUDE.md +++ b/rs/AGENTS.md @@ -1,4 +1,4 @@ -The `/rs` Cargo workspace. Extends the root `CLAUDE.md`. +The `/rs` Cargo workspace. Extends the root `AGENTS.md`. # Crates @@ -8,7 +8,7 @@ One crate per component from the root list, named `moq-` (`hang` and - `moq-tokio`: configures the QUIC backends (Quinn/Quiche/Noq/Iroh) and the fallback transports for native binaries, on tokio. `moq-sock` holds the socket plumbing it shares with `moq-uring`, the experimental thread-per-core io\_uring runtime. - `moq-cli` builds the `moq` binary and owns the CLI surface for the gateway crates. Binaries never carry a `-cli` suffix. -`moq-net`, `moq-mux`, `moq-relay` (including the config conventions every binary shares), and `moq-ffi` have their own `CLAUDE.md`. +`moq-net`, `moq-mux`, `moq-relay` (including the config conventions every binary shares), and `moq-ffi` have their own `AGENTS.md`. # Producer / Consumer diff --git a/rs/justfile b/rs/justfile index abeafecf82..93d4114d2e 100644 --- a/rs/justfile +++ b/rs/justfile @@ -730,7 +730,7 @@ relay-minimal: # # Outside nextest on purpose. The staticlib link alone takes ~2 minutes, past the # hang budget in `.config/nextest.toml` once the rest of the workspace builds -# beside it, and `rs/CLAUDE.md` says to make a slow test fast, not to raise the +# beside it, and `rs/AGENTS.md` says to make a slow test fast, not to raise the # ceiling. obs.yml runs this on every PR that touches rs/libmoq, right after it # has built the same debug libmoq.a for the plugin, and nightly.yml covers the # paths that filter misses. @@ -750,22 +750,22 @@ c-tests: cargo build --locked -p libmoq - # Ask cargo where it put the staticlib rather than assuming target/debug: - # CARGO_TARGET_DIR and a configured build target (`target//debug`) - # both move it, and guessing wrong would link a stale copy or nothing. - # Replays the build above from cache, so it costs a process, not a compile. - lib=$(cargo build --locked -p libmoq --message-format=json | - jq -r 'select(.reason == "compiler-artifact") | .filenames[] | select(endswith("/libmoq.a"))' | - tail -1) + # Ask cargo where it put the staticlib and the header rather than assuming + # target/debug: CARGO_TARGET_DIR and a configured build target + # (`target//debug`) both move them, and guessing wrong would link a + # stale copy or nothing. Replays the build above from cache, so it costs a + # process, not a compile. + log=$(cargo build --locked -p libmoq --message-format=json) + lib=$(jq -r 'select(.reason == "compiler-artifact") | .filenames[] | select(endswith("/libmoq.a"))' <<<"$log" | tail -1) if [ -z "$lib" ]; then echo "cargo reported no libmoq.a for libmoq" >&2 exit 1 fi - # The header sits beside the profile directory: rs/libmoq/build.rs writes it - # to `/include`, one level above `//libmoq.a`. profile=$(dirname "$lib") - include="$(dirname "$profile")/include" - if [ ! -f "$include/moq.h" ]; then + # rs/libmoq/build.rs writes the header into its OUT_DIR. + out_dir=$(jq -r 'select(.reason == "build-script-executed") | select(.package_id | test("libmoq")) | .out_dir' <<<"$log" | tail -1) + include="$out_dir/include" + if [ -z "$out_dir" ] || [ ! -f "$include/moq.h" ]; then echo "$include/moq.h missing after 'cargo build --locked -p libmoq'" >&2 exit 1 fi diff --git a/rs/libmoq/CMakeLists.txt b/rs/libmoq/CMakeLists.txt index 50b7359811..9c14c080ad 100644 --- a/rs/libmoq/CMakeLists.txt +++ b/rs/libmoq/CMakeLists.txt @@ -36,13 +36,14 @@ if(BUILD_RUST_LIB) set(CARGO_BUILD_FLAG "--release") endif() - set(RUST_TARGET_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../target") - set(RUST_DIR "${RUST_TARGET_DIR}/${CARGO_BUILD_TYPE}") + set(RUST_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../target/${CARGO_BUILD_TYPE}") set(RUST_LIB "${RUST_DIR}/${LIB_PREFIX}moq${LIB_SUFFIX}") - set(RUST_HEADER "${RUST_TARGET_DIR}/include/moq.h") + set(RUST_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/include") + set(RUST_HEADER "${RUST_INCLUDE_DIR}/moq.h") add_custom_target(rust_build ALL - COMMAND cargo build --locked ${CARGO_BUILD_FLAG} + COMMAND ${CMAKE_COMMAND} -DCARGO_BUILD_FLAG=${CARGO_BUILD_FLAG} -DHEADER=${RUST_HEADER} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/cargo-build.cmake WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} BYPRODUCTS ${RUST_LIB} ${RUST_HEADER} COMMENT "Building Rust library" @@ -50,18 +51,18 @@ if(BUILD_RUST_LIB) ) else() set(RUST_DIR "${CMAKE_CURRENT_SOURCE_DIR}" CACHE PATH "Directory containing pre-built library and header") - set(RUST_TARGET_DIR "${RUST_DIR}") set(RUST_LIB "${RUST_DIR}/${LIB_PREFIX}moq${LIB_SUFFIX}") - set(RUST_HEADER "${RUST_DIR}/include/moq.h") + set(RUST_INCLUDE_DIR "${RUST_DIR}/include") + set(RUST_HEADER "${RUST_INCLUDE_DIR}/moq.h") endif() # Ensure the directories exist (cargo build creates them) file(MAKE_DIRECTORY ${RUST_DIR}) -file(MAKE_DIRECTORY ${RUST_TARGET_DIR}/include) +file(MAKE_DIRECTORY ${RUST_INCLUDE_DIR}) # Create interface library target that wraps the Rust static library add_library(moq INTERFACE) -target_include_directories(moq INTERFACE ${RUST_TARGET_DIR}/include) +target_include_directories(moq INTERFACE ${RUST_INCLUDE_DIR}) # Link the static library - must use target_link_libraries so it appears # after object files in the link command (linker order matters for static libs) diff --git a/rs/libmoq/README.md b/rs/libmoq/README.md index 07e5d34724..3892cd8d6b 100644 --- a/rs/libmoq/README.md +++ b/rs/libmoq/README.md @@ -11,8 +11,14 @@ cargo build --release This will: - Build the static library (`libmoq.a` on Unix-like systems, `moq.lib` on Windows) -- Generate the C header file at `target/include/moq.h` -- Generate the pkg-config file at `target/release/lib/pkgconfig/moq.pc` +- Generate the C header file at `$OUT_DIR/include/moq.h` +- Generate the pkg-config file at `$OUT_DIR/lib/pkgconfig/moq.pc` + +`OUT_DIR` is the build script's hashed output directory, which +`cargo build --message-format=json` reports as `out_dir` on the +`build-script-executed` message for libmoq. +`moq.pc` assumes the install layout (`lib/libmoq.a` beside `lib/pkgconfig/`), so +copy the staticlib into `$OUT_DIR/lib/` or a prefix before using it. There's also a [CMakeLists.txt](CMakeLists.txt) file that can be used to import/build the library. @@ -63,7 +69,7 @@ int32_t moq_origin_announced_cancel(uint32_t announced); // Publishing int32_t moq_publish_announce(uint32_t broadcast, const moq_route *route); int32_t moq_publish_unannounce(uint32_t broadcast); -int32_t moq_publish_finish(uint32_t broadcast); +int32_t moq_publish_close(uint32_t broadcast); int32_t moq_publish_audio(uint32_t broadcast, const moq_audio_init *config); int32_t moq_publish_video(uint32_t broadcast, const moq_video_init *config); int32_t moq_publish_container(uint32_t broadcast, const moq_container_init *config); diff --git a/rs/libmoq/build.rs b/rs/libmoq/build.rs index 639c3521b4..3ba1a40b7f 100644 --- a/rs/libmoq/build.rs +++ b/rs/libmoq/build.rs @@ -22,17 +22,18 @@ const ENUMS: &[&str] = &[ fn main() { let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let version = env::var("CARGO_PKG_VERSION").unwrap(); - let profile_dir = profile_dir(); - let target_dir = profile_dir.parent().expect("profile dir has no parent"); + // Everything lands in OUT_DIR, laid out like the install prefix minus the + // staticlib. Cargo forbids writing anywhere else, and a build cache that + // replays this script (mbx) restores OUT_DIR and nothing more. Consumers ask + // cargo for the path (`build-script-executed` in --message-format=json). + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // The `rerun-if-changed` below opts out of cargo's default "rerun when any // file in the package changes", so the sources cbindgen reads have to be // named explicitly or an edited signature leaves a stale header behind. println!("cargo:rerun-if-changed=src"); - // Generate C header into target/include/. The header is profile-independent, - // so a debug and release build in the same target tree can share it. - let include_dir = target_dir.join("include"); + let include_dir = out_dir.join("include"); fs::create_dir_all(&include_dir).expect("Failed to create include directory"); let header = include_dir.join(format!("{}.h", LIB_NAME)); let config = cbindgen::Config { @@ -59,10 +60,8 @@ fn main() { .expect("Unable to generate bindings") .write_to_file(&header); - // Generate the pkg-config file next to the staticlib under the profile dir. Scoping it per profile - // keeps debug and release builds from clobbering each other's moq.pc. let pc_in = PathBuf::from(&crate_dir).join(format!("{}.pc.in", LIB_NAME)); - let pkgconfig_dir = profile_dir.join("lib").join("pkgconfig"); + let pkgconfig_dir = out_dir.join("lib").join("pkgconfig"); fs::create_dir_all(&pkgconfig_dir).expect("Failed to create pkgconfig directory"); let pc_out = pkgconfig_dir.join(format!("{}.pc", LIB_NAME)); if let Ok(template) = fs::read_to_string(&pc_in) { @@ -105,17 +104,3 @@ fn native_libs(crate_dir: &str, target: &str) -> String { .collect::>() .join(" ") } - -fn profile_dir() -> PathBuf { - // OUT_DIR is set by Cargo based on whether --target is used: - // - With --target: target/{target}/{profile}/build/{crate}-{hash}/out - // - Without --target: target/{profile}/build/{crate}-{hash}/out - // Go up 3 levels to the profile dir. Deriving this from OUT_DIR also handles custom profiles, - // whose output directory can differ from Cargo's PROFILE value. - PathBuf::from(env::var("OUT_DIR").unwrap()) - .parent() // build/{crate}-{hash} - .and_then(|p| p.parent()) // build/ - .and_then(|p| p.parent()) // {profile}/ - .expect("Failed to get profile directory from OUT_DIR") - .to_path_buf() -} diff --git a/rs/libmoq/build.sh b/rs/libmoq/build.sh index dc40979258..7eb7ff5ba3 100755 --- a/rs/libmoq/build.sh +++ b/rs/libmoq/build.sh @@ -75,12 +75,17 @@ mkdir -p "$PACKAGE_DIR/include" "$PACKAGE_DIR/lib" if [[ "$TARGET" == *"-windows-"* ]]; then echo "Building libmoq for $TARGET via cargo (Windows path)..." - cargo build --locked --release --package libmoq --target "$TARGET" --manifest-path "$WORKSPACE_DIR/Cargo.toml" + BUILD_LOG="$(mktemp)" + trap 'rm -f "$BUILD_LOG"' EXIT + cargo build --locked --release --package libmoq --target "$TARGET" --manifest-path "$WORKSPACE_DIR/Cargo.toml" \ + --message-format=json-render-diagnostics >"$BUILD_LOG" TARGET_DIR="$WORKSPACE_DIR/target/$TARGET/release" LIB_FILE="moq.lib" cp "$TARGET_DIR/$LIB_FILE" "$PACKAGE_DIR/lib/" - cp "$WORKSPACE_DIR/target/$TARGET/include/moq.h" "$PACKAGE_DIR/include/" + # build.rs writes the header into its OUT_DIR, which only cargo's JSON names. + GEN_DIR=$(jq -r 'select(.reason == "build-script-executed") | select(.package_id | test("libmoq")) | .out_dir' "$BUILD_LOG" | tail -1) + cp "$GEN_DIR/include/moq.h" "$PACKAGE_DIR/include/" # Generate CMake config files from templates (no pkg-config on Windows). mkdir -p "$PACKAGE_DIR/lib/cmake/moq" diff --git a/rs/libmoq/c-tests/decode-output.c b/rs/libmoq/c-tests/decode-output.c index 585f107bf2..057f33e62d 100644 --- a/rs/libmoq/c-tests/decode-output.c +++ b/rs/libmoq/c-tests/decode-output.c @@ -284,8 +284,8 @@ int main(void) { fail("error: moq_consume_close failed (%s)\n", moq_error()); if (moq_encode_video_finish((uint32_t)producer) < 0) fail("error: moq_encode_video_finish failed (%s)\n", moq_error()); - if (moq_publish_finish((uint32_t)broadcast) < 0) - fail("error: moq_publish_finish failed (%s)\n", moq_error()); + if (moq_publish_close((uint32_t)broadcast) < 0) + fail("error: moq_publish_close failed (%s)\n", moq_error()); if (moq_origin_close((uint32_t)origin) < 0) fail("error: moq_origin_close failed (%s)\n", moq_error()); diff --git a/rs/libmoq/cmake/cargo-build.cmake b/rs/libmoq/cmake/cargo-build.cmake new file mode 100644 index 0000000000..4a0316a31f --- /dev/null +++ b/rs/libmoq/cmake/cargo-build.cmake @@ -0,0 +1,17 @@ +# Run with `cmake -DCARGO_BUILD_FLAG=... -DHEADER=... -P cargo-build.cmake` from rs/libmoq. +# +# Builds libmoq, then copies moq.h to HEADER. build.rs writes the header into +# its OUT_DIR, whose hashed path only cargo's JSON messages name. +execute_process( + COMMAND cargo build --locked ${CARGO_BUILD_FLAG} --message-format=json-render-diagnostics + OUTPUT_VARIABLE _messages + COMMAND_ERROR_IS_FATAL ANY +) + +string(REGEX MATCH "{\"reason\":\"build-script-executed\",\"package_id\":\"[^\"]*/libmoq#[^\n]*" _message "${_messages}") +if(NOT _message) + message(FATAL_ERROR "cargo reported no build script output for libmoq") +endif() +string(JSON _out_dir GET "${_message}" out_dir) + +file(COPY_FILE "${_out_dir}/include/moq.h" "${HEADER}" ONLY_IF_DIFFERENT) diff --git a/rs/libmoq/moq.pc.in b/rs/libmoq/moq.pc.in index 4bb6f1d771..fc21229333 100644 --- a/rs/libmoq/moq.pc.in +++ b/rs/libmoq/moq.pc.in @@ -1,11 +1,8 @@ -# Paths are relative to this .pc file so the raw `cargo build` target tree is -# usable directly (e.g. PKG_CONFIG_PATH=target//lib/pkgconfig). The -# staticlib sits in the profile root two dirs up (target//); the header -# is shared across profiles in target/include/. -# Packagers that relocate into a conventional lib/+include/ layout rewrite -# libdir; see nix/overlay.nix. -libdir=${pcfiledir}/../.. -includedir=${pcfiledir}/../../../include +# Paths are relative to this .pc file, in the install layout the release +# tarballs and nix/overlay.nix ship: lib/{libmoq.a,pkgconfig/moq.pc} and +# include/moq.h under one prefix. +libdir=${pcfiledir}/.. +includedir=${pcfiledir}/../../include Name: moq Description: Media over QUIC C Interface diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 5ea04e4f1e..987d9de8d4 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -1564,7 +1564,7 @@ pub extern "C" fn moq_origin_create() -> i32 { /// /// The broadcast is invisible and unroutable, on this origin and its peers /// alike, until [moq_publish_announce]. Fill it with the `moq_publish_*` -/// functions, then announce it. [moq_publish_finish] unpublishes immediately. +/// functions, then announce it. [moq_publish_close] ends it for good. /// /// Returns a non-zero broadcast handle on success, or a negative code on failure. /// @@ -1948,20 +1948,29 @@ pub extern "C" fn moq_publish_unannounce(broadcast: u32) -> i32 { }) } -/// Finish a broadcast and release it, ending its catalog cleanly. +/// End a broadcast for good and release its handle. /// -/// Subscribers see a normal end of stream rather than an error, and the origin unpublishes -/// the path immediately. +/// The origin retracts the path immediately and serves no new tracks; tracks already +/// subscribed carry on to their own end. The handle is invalid afterwards, so closing +/// it again fails like any unknown handle. /// /// Returns a zero on success, or a negative code on failure. #[unsafe(no_mangle)] -pub extern "C" fn moq_publish_finish(broadcast: u32) -> i32 { +pub extern "C" fn moq_publish_close(broadcast: u32) -> i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; - State::lock().publish.finish(broadcast) + State::lock().publish.close(broadcast) }) } +/// Deprecated: use [moq_publish_close]. A broadcast end carries no cause. +/// +/// Returns a zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_finish(broadcast: u32) -> i32 { + moq_publish_close(broadcast) +} + /// Publish one audio codec as a new media track. /// /// The track is named after the format (`0.opus`), so a subscriber finds it @@ -2237,6 +2246,18 @@ pub extern "C" fn moq_publish_media_flush(media: u32, timestamp_us: u64) -> i32 }) } +/// Mark a timeline break and restart handoff measurement without lowering advertised jitter. +/// +/// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock. +/// Returns zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_media_discontinuity(media: u32) -> i32 { + ffi::enter(move || { + let media = ffi::parse_id(media)?; + State::lock().publish.media_discontinuity(media) + }) +} + /// Replace the catalog properties shared by every video rendition. /// /// Rotation is clockwise and normalized to the nearest quarter turn. A field whose matching `has_*` flag is false is removed from the next catalog update. diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index fd7db2f681..27b3d05457 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -162,9 +162,8 @@ impl Publish { Ok((&mut broadcast.producer, &mut broadcast.catalog)) } - /// Cleanly finish the broadcast and finalize the catalog stream, so subscribers - /// see a normal end rather than [`moq_net::Error::Dropped`]. - pub fn finish(&mut self, broadcast: Id) -> Result<(), Error> { + /// End the broadcast for good and release it, finalizing the catalog stream. + pub fn close(&mut self, broadcast: Id) -> Result<(), Error> { let Broadcast { producer, mut catalog, @@ -183,9 +182,8 @@ impl Publish { } guard.commit()?; } - // Finish the broadcast first so the clean end reaches subscribers even if - // finalizing the catalog fails. - producer.finish(); + // Close the broadcast first so it ends even if finalizing the catalog fails. + producer.close(); catalog.finish()?; Ok(()) } @@ -245,6 +243,13 @@ impl Publish { Ok(()) } + /// Mark a timeline break on this media importer. + pub fn media_discontinuity(&mut self, media: Id) -> Result<(), Error> { + let track = self.media.get_mut(media).ok_or(Error::MediaNotFound)?; + track.discontinuity()?; + Ok(()) + } + /// Draw a group boundary on this media importer. /// /// This ends the open group; the next frame starts a new one. Audio has no boundary of its own diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index 4aa1d5d2b6..1f6935011c 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -367,7 +367,7 @@ fn publish_media_lifecycle() { let origin = id(moq_origin_create()); let broadcast = publish_broadcast(origin, b"publish-media-lifecycle"); let _guard = Guard(Some(|| { - moq_publish_finish(broadcast); + moq_publish_close(broadcast); })); let init = opus_head(); @@ -379,7 +379,7 @@ fn publish_media_lifecycle() { assert_eq!(ret, 0, "moq_publish_media_frame should succeed"); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -389,7 +389,7 @@ fn publish_media_rejects_a_null_config() { assert!(unsafe { moq_publish_audio(broadcast, std::ptr::null()) } < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -424,7 +424,7 @@ fn container_and_media_handles_are_not_interchangeable() { assert_eq!(moq_publish_container_finish(container), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -511,7 +511,7 @@ fn publish_media_labels_config_without_naming_track() { assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media1), 0); assert_eq!(moq_publish_media_finish(media2), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -621,7 +621,7 @@ fn publish_media_owns_its_rendition_before_the_first_keyframe() { "finishing the media track releases its rendition name" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -667,7 +667,7 @@ fn publish_video_config_replaces_its_own_rendition() { "the name is free once the caller removes its rendition" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -690,7 +690,7 @@ fn publish_catalog_config_null_pointer() { -6, "null config should return InvalidPointer (-6)" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -891,7 +891,7 @@ fn publish_catalog_roundtrip() { assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -963,13 +963,13 @@ fn a_half_specified_coded_size_round_trips() { assert_eq!(moq_consume_catalog_cancel(forwarded_task), 0); assert_eq!(forwarded_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(forwarded), 0); - assert_eq!(moq_publish_finish(forward), 0); + assert_eq!(moq_publish_close(forward), 0); assert_eq!(moq_consume_catalog_free(catalog), 0); assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1065,7 +1065,7 @@ fn raw_loc_video_uses_the_declared_catalog_container() { assert_eq!(catalog_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1129,7 +1129,7 @@ fn cmaf_catalog_container_carries_its_init_segment() { assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1189,7 +1189,7 @@ fn unpublishable_catalog_containers_are_rejected() { "cmaf without an init segment should return InvalidPointer (-6)" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1344,7 +1344,7 @@ fn catalog_section_roundtrip() { assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1391,7 +1391,7 @@ fn publish_track_with_info_rejects_invalid_timescale() { }; assert!(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), &info) } < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1491,7 +1491,7 @@ fn raw_track_publish_consume() { assert_eq!(moq_publish_track_finish(track), 0); assert!(moq_publish_track_finish(track) < 0, "double-close should fail"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1553,7 +1553,7 @@ fn raw_track_datagram_publish_consume() { assert!(moq_consume_datagrams_cancel(consumer) < 0, "double-close should fail"); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1572,7 +1572,7 @@ fn raw_track_sparse_groups_and_known_end() { assert_eq!(moq_publish_group_finish(group), 0); assert!(moq_publish_track_group_at(track, 5) < 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1587,7 +1587,7 @@ fn raw_track_and_group_abort_consume_their_handles() { assert!(moq_publish_group_finish(group) < 0); assert_eq!(moq_publish_track_abort(track, 410), 0); assert!(moq_publish_track_finish(track) < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1673,7 +1673,7 @@ fn raw_track_subscription_options_and_update() { assert_eq!(frame_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1739,7 +1739,7 @@ fn json_snapshot_publish_consume() { "double-close should fail" ); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1799,7 +1799,7 @@ fn json_stream_publish_consume() { assert_eq!(moq_publish_json_stream_finish(producer), 0); assert!(moq_publish_json_stream_finish(producer) < 0, "double-close should fail"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1807,13 +1807,26 @@ fn json_stream_publish_consume() { fn close_invalid_or_zero_ids() { assert!(moq_origin_close(9999) < 0); assert!(moq_session_close(9999) < 0); - assert!(moq_publish_finish(9999) < 0); + assert!(moq_publish_close(9999) < 0); assert!(moq_consume_close(9999) < 0); assert!(moq_consume_frame_free(9999) < 0); assert!(moq_origin_close(0) < 0); assert!(moq_session_close(0) < 0); - assert!(moq_publish_finish(0) < 0); + assert!(moq_publish_close(0) < 0); +} + +#[test] +fn publish_close_releases_the_handle() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"close/twice"); + assert_eq!(moq_publish_close(broadcast), 0); + assert!(moq_publish_close(broadcast) < 0, "a closed handle is released"); + + // The deprecated alias still ends a broadcast. + let broadcast = publish_broadcast(origin, b"close/finish"); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); } #[test] @@ -1855,7 +1868,7 @@ fn announced_free_lifecycle() { ann_cb.recv_terminal(); assert_eq!(moq_origin_close(origin), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1872,7 +1885,7 @@ fn double_close_all_resource_types() { assert_eq!(moq_publish_media_finish(media), 0); assert!(moq_publish_media_finish(media) < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); let origin = id(moq_origin_create()); let path = b"double-close-test"; @@ -1912,7 +1925,7 @@ fn double_close_all_resource_types() { assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1943,15 +1956,18 @@ fn media_cut_bounds_audio_groups() { 0 ); assert_eq!(moq_publish_media_seek(media, 42), 0); + assert_eq!(moq_publish_media_discontinuity(media), 0); // Both report a missing importer rather than panicking on an unknown id. assert!(moq_publish_media_flush(9999, 0) < 0); assert!(moq_publish_media_flush(media, u64::MAX) < 0); + assert!(moq_publish_media_discontinuity(9999) < 0); + assert!(moq_publish_media_discontinuity(0) < 0); assert!(moq_publish_media_cut(9999) < 0); assert!(moq_publish_media_seek(9999, 0) < 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1960,7 +1976,7 @@ fn unknown_format() { let origin = id(moq_origin_create()); let broadcast = publish_broadcast(origin, b"unknown-format"); let _guard = Guard(Some(|| { - moq_publish_finish(broadcast); + moq_publish_close(broadcast); })); // A format is an enum now, so the only bad value C can still supply is an out-of-range @@ -2005,7 +2021,7 @@ fn local_announce() { assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0, "announced close delivers terminal 0"); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2049,8 +2065,8 @@ fn announced_filters_patterns_and_reports_captures() { assert_eq!(moq_origin_announced_free(announced_id), 0); assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(audio), 0); - assert_eq!(moq_publish_finish(chat), 0); + assert_eq!(moq_publish_close(audio), 0); + assert_eq!(moq_publish_close(chat), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2146,7 +2162,7 @@ fn announced_deactivation() { assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0, "announced close delivers terminal 0"); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2177,7 +2193,7 @@ fn create_broadcast_is_unroutable_until_announced() { assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2195,7 +2211,7 @@ fn announce_accepts_an_anonymous_hop() { has_cold: false, }; assert_eq!(unsafe { moq_publish_announce(broadcast, &route) }, 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2244,7 +2260,7 @@ fn dynamic_serves_a_request_under_a_prefix() { assert_eq!(moq_origin_dynamic_cancel(dynamic), 0); assert!(moq_origin_dynamic_cancel(dynamic) < 0, "double-cancel should fail"); assert_eq!(cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(served), 0); + assert_eq!(moq_publish_close(served), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2374,7 +2390,7 @@ fn track_demand_follows_subscribers() { ); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2407,7 +2423,7 @@ fn track_demand_reports_current_state_before_close() { ); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2430,7 +2446,7 @@ fn track_demand_reports_an_abort() { assert_eq!(protocol.kind, moq_protocol_kind::MOQ_PROTOCOL_KIND_APP as u32); assert_eq!(protocol.code, 64 + 7); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2467,7 +2483,7 @@ fn media_demand_refuses_a_container() { assert_eq!(cb.recv_terminal(), 0); assert_eq!(moq_publish_container_finish(container), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2570,7 +2586,7 @@ fn dynamic_serves_track_requests() { assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(demand_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2614,7 +2630,7 @@ fn dynamic_track_request_publishes_media() { assert_eq!(demand_cb.recv(), moq_demand::MOQ_DEMAND_UNUSED as i32); assert_eq!(moq_publish_media_finish(media), 0); assert_eq!(demand_cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(request_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_origin_close(origin), 0); @@ -2699,7 +2715,7 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2734,7 +2750,7 @@ fn track_dynamic_serves_a_fetch_from_frame_start() { assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2768,7 +2784,7 @@ fn track_request_dynamic_survives_accept() { assert_eq!(moq_publish_dynamic_cancel(track_dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(request_cb.recv_terminal(), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2866,7 +2882,7 @@ fn local_publish_consume() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2923,7 +2939,7 @@ fn consume_announced_local() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3025,8 +3041,8 @@ fn consume_audio_follows_a_sibling_broadcast_reference() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); - assert_eq!(moq_publish_finish(source), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_publish_close(source), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3153,7 +3169,7 @@ fn video_publish_consume() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3208,7 +3224,7 @@ fn audio_raw_publish() { "a finished producer should take no more frames" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3263,7 +3279,7 @@ fn audio_raw_publish_frame_durations() { assert_eq!(moq_encode_audio_finish(id(encode(b"default", 0))), 0, "0 = 20 ms"); assert!(encode(b"rounded", 2_000) < 0, "2 ms is not an opus frame duration"); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3380,7 +3396,7 @@ fn video_raw_publish_consume() { assert_eq!(catalog_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_encode_video_finish(producer), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3518,7 +3534,7 @@ fn decode_first_frame(output: &moq_video_decoder_output) -> (u32, u32, usize) { assert_eq!(catalog_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_encode_video_finish(producer), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); result } @@ -3603,7 +3619,7 @@ fn video_raw_publish_from_many_threads() { .join() .unwrap(); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3699,7 +3715,7 @@ fn a_stalled_encode_does_not_block_unrelated_calls() { assert_eq!(moq_origin_close(id(created)), 0); assert_eq!(moq_encode_video_finish(stalled), 0); assert_eq!(moq_encode_video_finish(other), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3737,7 +3753,7 @@ fn video_raw_publish_rejects_frame_size_mismatch() { assert!(unsafe { moq_encode_video_frame(producer, &frame) } < 0); assert_eq!(moq_encode_video_finish(producer), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3826,7 +3842,7 @@ fn video_raw_publish_rejects_invalid_config() { assert!(moq_encode_video_bitrate(0, 1_000_000) < 0); assert!(moq_encode_video_finish(0) < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3926,7 +3942,7 @@ fn video_raw_decode() { } assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3986,7 +4002,7 @@ fn multiple_frames_ordering() { ); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4035,7 +4051,7 @@ fn catalog_update_on_new_track() { assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media1), 0); assert_eq!(moq_publish_media_finish(media2), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4574,7 +4590,7 @@ fn bandwidth_reservations_split_the_estimate() { let _ = first_cb.recv_terminal(); let _ = second_cb.recv_terminal(); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4617,7 +4633,7 @@ fn bandwidth_handles_share_the_registry() { let _ = first_cb.recv_terminal(); let _ = second_cb.recv_terminal(); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4667,7 +4683,7 @@ fn encode_video_bitrate_caps_the_reservation() { assert_eq!(moq_encode_video_finish(producer), 0); assert_eq!(moq_bandwidth_close(bandwidth), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4786,7 +4802,7 @@ fn server_accepts_a_session() { "the server handle is gone after its terminal callback" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(served), 0); assert_eq!(moq_origin_close(received), 0); } @@ -4956,7 +4972,7 @@ fn json_tracks_are_advertised_in_the_catalog() { assert_eq!(moq_publish_json_stream_finish(stream), 0); assert!(published_catalog(broadcast).json.tracks.is_empty()); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -5004,7 +5020,7 @@ fn binary_snapshot_is_advertised_and_delivered() { assert!(published_catalog(broadcast).binary.tracks.is_empty()); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -5050,7 +5066,7 @@ fn binary_stream_is_advertised_and_delivered() { assert!(published_catalog(broadcast).binary.tracks.is_empty()); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -5112,6 +5128,6 @@ fn data_track_names_cannot_collide() { ); assert_eq!(moq_publish_json_snapshot_finish(first), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } diff --git a/rs/moq-bench/src/connection.rs b/rs/moq-bench/src/connection.rs index 9dcc527ec8..e79f0eb1fc 100644 --- a/rs/moq-bench/src/connection.rs +++ b/rs/moq-bench/src/connection.rs @@ -799,7 +799,7 @@ mod tests { task.await.unwrap().unwrap(); assert_eq!(stats.groups_recv.load(Ordering::Relaxed), 1); - broadcast.finish(); + broadcast.close(); } /// The relay fails a group it gave up on (`Error::Lagged` once a subscriber @@ -850,7 +850,7 @@ mod tests { write_group(&mut track); wait_for(&stats.groups_recv, 3).await; track.finish().unwrap(); - broadcast.finish(); + broadcast.close(); task.await .unwrap() diff --git a/rs/moq-boy/src/main.rs b/rs/moq-boy/src/main.rs index a167fbec44..df54bf2baa 100644 --- a/rs/moq-boy/src/main.rs +++ b/rs/moq-boy/src/main.rs @@ -305,9 +305,8 @@ async fn run(config: &Config) -> Result<()> { res = input::handle_viewers(&viewer_consumer, &cmd_tx) => res, }; - // Cleanly close the broadcast so subscribers see a normal end rather than - // Error::Dropped. - broadcast.finish(); + // Close the broadcast now, even if the emulator thread still holds a clone. + broadcast.close(); result } diff --git a/rs/moq-ffi/CLAUDE.md b/rs/moq-ffi/AGENTS.md similarity index 100% rename from rs/moq-ffi/CLAUDE.md rename to rs/moq-ffi/AGENTS.md diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index 0649d29b28..6480e9a8af 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -515,7 +515,7 @@ impl MoqAnnounceUpdate { impl MoqAnnouncedBroadcast { /// Wait until the broadcast is announced. Returns `Closed` if cancelled or the origin is closed. /// - /// Use `broadcast.closed()` to learn when the broadcast ends. + /// Its end arrives as an inactive [`MoqAnnounceUpdate`] on the origin's announcements. pub async fn available(&self) -> Result, MoqError> { self.task.run(|mut state| async move { state.available().await }).await } diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 022bc5c5be..847baa8a56 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -173,7 +173,7 @@ impl MoqBroadcastProducer { } /// Run `f` against the open broadcast and catalog. Errors with - /// [`MoqError::Closed`] if `finish()` has already run. Used by + /// [`MoqError::Closed`] if `close()` has already run. Used by /// sibling modules (e.g. `audio`) that need joint access. pub(crate) fn with_state( &self, @@ -473,18 +473,27 @@ impl MoqBroadcastProducer { })) } - /// Finish this publisher, finalizing the catalog stream and cleanly closing the - /// broadcast so subscribers see a normal end rather than `Error::Dropped`. - pub fn finish(&self) -> Result<(), MoqError> { + /// End the broadcast for good: retract it, serve no new tracks, and finalize the catalog. + /// + /// Tracks already subscribed carry on to their own end. Every later call on this + /// producer fails with `Closed`; closing again is a no-op. + pub fn close(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); + // Hold the lock through shutdown so a concurrent close() returns only once it is done. let mut guard = self.state.lock().unwrap(); - let mut state = guard.take().ok_or(MoqError::Closed)?; - // Finish the broadcast first so the clean end reaches subscribers even if - // finalizing the catalog fails. - state.broadcast.finish(); + let Some(mut state) = guard.take() else { + return Ok(()); + }; + // Close the broadcast first so it ends even if finalizing the catalog fails. + state.broadcast.close(); state.catalog.finish()?; Ok(()) } + + /// Deprecated: use `close()`. A broadcast end carries no cause. + pub fn finish(&self) -> Result<(), MoqError> { + self.close() + } } // ---- Dynamic Broadcast Producer ---- @@ -974,6 +983,17 @@ impl MoqMediaProducer { Ok(()) } + /// Mark a timeline break and restart handoff measurement without lowering advertised jitter. + /// + /// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock. + pub fn discontinuity(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let mut guard = self.inner.lock().unwrap(); + let media = guard.as_mut().ok_or(MoqError::Closed)?; + media.import.discontinuity()?; + Ok(()) + } + /// Draw a group boundary here. /// /// Audio has no boundary of its own (every packet is independently decodable), so this is the diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index fa39dc0c42..19ec67a80e 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -332,7 +332,7 @@ async fn announced_route_keeps_cold_cost_on_reannounce() { assert_eq!(back.cost, moq_net::origin::Cost { warm: 0, cold: 9 }); assert_eq!(MoqRoute::from(back), route); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[test] @@ -347,7 +347,7 @@ fn publish_media_lifecycle() { }) .unwrap(); media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -467,7 +467,7 @@ async fn raw_audio_activity() { assert_eq!(resumed.timestamp_us, RESUMED_TIMESTAMP_US); audio.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// `frame_duration_us` is microseconds so Opus' 2.5 ms frame survives the trip, where @@ -509,7 +509,7 @@ async fn raw_audio_frame_durations() { "2 ms is not an opus frame duration" ); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -1368,7 +1368,9 @@ async fn dynamic_track_request_can_publish_media() { assert_eq!(frame.payload, payload); assert_eq!(frame.timestamp_us, 20_000); + media.discontinuity().unwrap(); media.finish().unwrap(); + assert!(matches!(media.discontinuity(), Err(MoqError::Closed))); } #[tokio::test] @@ -1482,7 +1484,7 @@ async fn create_broadcast_is_invisible_until_announced() { .expect("the cursor is still open"); assert_eq!(update.prefix(), "live"); assert!(update.active()); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// Waiting for an exact path must hand the broadcast back named by that path, the base a @@ -1509,7 +1511,7 @@ async fn announced_broadcast_keeps_the_requested_path() { .unwrap(); assert_eq!(requested.inner().info().path.as_str(), "a/pub"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// A catalog rendition may name a sibling broadcast (`./source`), and the track then lives @@ -1550,8 +1552,8 @@ async fn decode_audio_follows_a_sibling_broadcast_reference() { "the catalog broadcast does not serve the track itself" ); - catalog.finish().unwrap(); - source.finish().unwrap(); + catalog.close().unwrap(); + source.close().unwrap(); } /// Announcement filters do not re-root the origin, so relative broadcast references @@ -1605,8 +1607,8 @@ async fn announced_broadcasts_resolve_siblings_under_the_prefix() { .expect("timed out subscribing on the resolved broadcast") .unwrap(); - catalog.finish().unwrap(); - source.finish().unwrap(); + catalog.close().unwrap(); + source.close().unwrap(); } #[tokio::test] @@ -1642,7 +1644,7 @@ async fn announced_filters_patterns_and_reports_captures() { assert_eq!(update.captures(), Some(vec!["alice".into()])); assert!(update.active()); - chat.finish().unwrap(); + chat.close().unwrap(); } /// A `.`-named broadcast is listed only when the config opts in or the prefix names it. @@ -1731,8 +1733,8 @@ async fn resolve_returns_a_broadcast_that_resolves_further_references() { .unwrap(); assert_eq!(back.inner().info().path.as_str(), "a/pub"); - catalog.finish().unwrap(); - source.finish().unwrap(); + catalog.close().unwrap(); + source.close().unwrap(); } #[tokio::test] @@ -1776,7 +1778,7 @@ async fn announce_and_unannounce_toggles_discovery() { .expect("timed out requesting the reannounced broadcast") .expect("a reannounced broadcast resolves"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -1793,7 +1795,7 @@ async fn finish_unpublishes() { // A graceful finish detaches immediately; the path stops resolving. Removal is // asynchronous, so poll until it takes effect. - broadcast.finish().unwrap(); + broadcast.close().unwrap(); let removed = tokio::time::timeout(TIMEOUT, async { loop { if consumer.request_broadcast("live".into()).await.is_err() { @@ -1862,7 +1864,7 @@ async fn local_publish_consume_audio() { assert_eq!(frame.payload, payload); assert_eq!(frame.timestamp_us, 1_000_000); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -1924,7 +1926,7 @@ async fn video_publish_consume() { assert_eq!(frame.timestamp_us, 0); assert!(!frame.payload.is_empty(), "frame should have payload data"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// The raw-video publish path: hand mid-gray RGBA to `encode_video` and check @@ -2037,7 +2039,7 @@ async fn video_raw_publish_consume() { assert!(!frame.payload.is_empty(), "frame should carry encoded video"); video.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// A decoded frame owns its surface and converts on demand: one frame yields @@ -2245,7 +2247,7 @@ async fn video_raw_publish_from_many_threads() { let closer = video.clone(); std::thread::spawn(move || closer.finish()).join().unwrap().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// A raw video producer rejects a buffer that isn't one picture at the @@ -2309,7 +2311,7 @@ async fn video_raw_publish_rejects_bad_frames() { Err(MoqError::Closed) )); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -2364,7 +2366,7 @@ async fn multiple_frames_ordering() { assert_eq!(frame.payload, expected.as_bytes(), "frame {i} has wrong payload"); } - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -2406,17 +2408,22 @@ async fn catalog_update_on_new_track() { assert_eq!(catalog2.audio["0.opus"].label.as_deref(), Some("English")); assert_eq!(catalog2.audio["1.opus"].label, None); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[test] -fn finish_closes_producer() { +fn close_twice_is_a_noop() { let broadcast = MoqBroadcastProducer::new().unwrap(); let init = opus_head(); - let _media = broadcast.publish_audio(audio_init(MoqAudioFormat::Opus, init)).unwrap(); - broadcast.finish().unwrap(); + let _media = broadcast + .publish_audio(audio_init(MoqAudioFormat::Opus, init.clone())) + .unwrap(); + broadcast.close().unwrap(); + broadcast.close().unwrap(); - let err = broadcast.finish().unwrap_err(); + let Err(err) = broadcast.publish_audio(audio_init(MoqAudioFormat::Opus, init)) else { + panic!("publishing after close succeeded"); + }; assert!( matches!(err, crate::error::MoqError::Closed), "expected Closed error, got {err}" @@ -2445,7 +2452,7 @@ async fn announced_broadcast() { .unwrap(); // Finish so consumers observe a deliberate end (the canonical end for a // publisher; dropping without finish reads as a failure). - _broadcast.finish().unwrap(); + _broadcast.close().unwrap(); } fn serve(origin: &MoqOriginProducer, prefix: &str) -> Arc { @@ -2498,7 +2505,7 @@ async fn dynamic_broadcast_request() { assert_eq!(frame.timestamp_us, 20_000); track.finish().unwrap(); - served.finish().unwrap(); + served.close().unwrap(); } /// A prefix serves requests beneath it; cancelling the handle @@ -2538,7 +2545,7 @@ async fn dynamic_serves_a_request_under_a_prefix() { crate::error::MoqProtocolKind::Unroutable, ); - served.finish().unwrap(); + served.close().unwrap(); } /// Tearing the origin down ends every handler with `Closed`. A parked request @@ -3280,7 +3287,7 @@ fn without_runtime() { announced.cancel(); client.cancel(); media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); drop(client); drop(consumer); drop(announcement); @@ -3381,7 +3388,7 @@ async fn server_client_roundtrip() { // Clean up. Exercise `shutdown()` on the client side and the underlying // `cancel(code)` on the server side, so both shutdown paths run. media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.shutdown(); server_session.cancel(0); server.cancel(); @@ -3453,10 +3460,10 @@ async fn server_client_roundtrip_auto_origin() { .await .expect("timed out waiting for the loopback broadcast") .expect("an auto-origin session should discover its own announcement"); - local_broadcast.finish().unwrap(); + local_broadcast.close().unwrap(); media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.shutdown(); server_session.cancel(0); server.cancel(); @@ -3662,7 +3669,7 @@ async fn request_per_session_publish_override() { .expect("expected an announcement"); assert_eq!(announcement.prefix(), "override-only"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.cancel(0); server_session.cancel(0); server.cancel(); @@ -3790,7 +3797,7 @@ async fn client_reconnects_and_resumes_announcements() { .expect("expected an announcement"); assert_eq!(announcement.prefix(), "after-reconnect"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.cancel(0); server_session.cancel(0); server.cancel(); @@ -4054,7 +4061,7 @@ async fn video_encoder_follows_a_shrinking_grant() { } video.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[cfg(feature = "video")] @@ -4131,7 +4138,7 @@ async fn set_bitrate_caps_a_later_bandwidth_grant() { assert_eq!(video.applied_bitrate(), 1_000_000); video.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } async fn one_shot_peers() -> (Arc, Arc, Arc) { diff --git a/rs/moq-ffi/uniffi.toml b/rs/moq-ffi/uniffi.toml new file mode 100644 index 0000000000..ddf0c572fe --- /dev/null +++ b/rs/moq-ffi/uniffi.toml @@ -0,0 +1,5 @@ +# Kotlin objects are AutoCloseable, and `close()` there releases the handle, +# which ends the broadcast like dropping the last Rust producer. A generated +# `close()` would collide with it. +[bindings.kotlin] +exclude = ["MoqBroadcastProducer.close"] diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index e07f6c0178..d1ca805abd 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -641,9 +641,8 @@ impl MoqSink { if let Some(err) = failure { gst::warning!(CAT, "finalize on stop: {err:?}"); } - // Finish the broadcast (a deliberate end, so no dropped-without-finish - // warning) before reaping the session task. - state.broadcast.finish(); + // Close the broadcast before reaping the session task. + state.broadcast.close(); state.session.stop(); self.notify_updates(updates); } @@ -1022,7 +1021,15 @@ impl MoqSink { /// Close the gate: an EOS earned from here on waits for the next entry to PLAYING. fn leave_playing(&self) { - self.control.lock().unwrap().playing = false; + let mut control = self.control.lock().unwrap(); + if !std::mem::replace(&mut control.playing, false) { + return; + } + // Running time stops in PAUSED while the encoder handoff clock keeps advancing. + for pad in self.obj().sink_pads() { + let pad = pad.downcast::().expect("sink pad type"); + pad.lifecycle().media.discontinuity(); + } } fn publish_finished(&self, finished: Finished) { @@ -1092,6 +1099,62 @@ mod tests { glib::Object::builder::().build() } + #[test] + fn a_playing_pause_cycle_does_not_raise_encoder_jitter() { + gst::init().unwrap(); + let sink = sink(); + sink.set_property("url", "https://127.0.0.1:1"); + sink.set_property("broadcast", "pause-test"); + let pad = sink + .request_pad_simple("sink_0") + .unwrap() + .downcast::() + .unwrap(); + sink.set_state(gst::State::Paused).unwrap(); + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); + let caps = gst::Caps::builder("audio/x-opus") + .field("rate", 48_000i32) + .field("channels", 2i32) + .build(); + assert!(matches!( + pad.lifecycle().media.observe_caps( + &broadcast, + &catalog, + ProducerOptions::new(&caps).with_track("audio").with_encoder(true) + ), + CapsOutcome::Active(_) + )); + let segment = gst::FormattedSegment::::new(); + pad.lifecycle().media.observe_segment(segment.upcast()); + sink.set_state(gst::State::Playing).unwrap(); + let anchor = Instant::now(); + for (pts, arrival) in [(0, 0), (20, 120), (40, 6_000), (60, 6_020)] { + if pts == 40 { + sink.set_state(gst::State::Paused).unwrap(); + sink.set_state(gst::State::Playing).unwrap(); + } + assert_eq!( + pad.lifecycle() + .media + .push_buffer( + Bytes::from_static(b"opus frame"), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + Duration::from_millis(arrival) + ) + .unwrap(), + PushOutcome::Published + ); + } + assert_eq!( + catalog.snapshot().audio.renditions["audio"].jitter, + Some(Duration::from_millis(100)) + ); + sink.set_state(gst::State::Null).unwrap(); + } + fn spec(element: &super::super::MoqSink, name: &str) -> glib::ParamSpec { element.find_property(name).unwrap() } diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index 6139339eb1..c2bf6d1f2f 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -49,13 +49,27 @@ struct Media { /// irregularly a local encoder delivers. Imports leave it off: their arrival times describe the /// file or network, not the encoder. encoder: bool, + /// Apply a timeline break before the next valid frame, using the normal write error path. + discontinuity: bool, + /// A video break closed the group, and a pause resumes mid-GOP, so deltas drop until a keyframe + /// opens the next one. + keyframe: bool, } impl Media { - /// Publish one frame at `micros` on the media clock, handed over at `now`. - fn write(&mut self, data: &Bytes, micros: u64, now: Instant) -> Result<()> { + /// Publish one frame at `micros` on the media clock, handed over at `now`. Returns false when a + /// delta frame is dropped while waiting for the keyframe after a break. + fn write(&mut self, data: &Bytes, micros: u64, now: Instant) -> Result { + if std::mem::take(&mut self.discontinuity) { + self.track.discontinuity()?; + self.keyframe = !self.audio; + } let ts = hang::container::Timestamp::from_micros(micros).ok(); - self.track.decode(data, ts)?; + match self.track.decode(data, ts) { + Err(moq_mux::Error::MissingKeyframe(_)) if self.keyframe => return Ok(false), + result => result?, + } + self.keyframe = false; // One group (one QUIC stream) per audio packet, so the relay forwards it without waiting for // the next. if self.audio { @@ -66,7 +80,7 @@ impl Media { let ts = ts.context("encoder frame timestamp out of range")?; self.track.flush(ts, now)?; } - Ok(()) + Ok(true) } } @@ -466,7 +480,13 @@ impl Pad { } other => anyhow::bail!("unsupported caps: {other}"), }; - self.track = Some(Sink::Media(Box::new(Media { track, audio, encoder }))); + self.track = Some(Sink::Media(Box::new(Media { + track, + audio, + encoder, + discontinuity: false, + keyframe: false, + }))); self.caps = Some(caps.clone()); Ok(name) } @@ -592,7 +612,14 @@ impl Pad { self.segment_info = Some(info); match classify_segment(prev.as_ref(), &info) { Ok(()) => { - self.segment = segment.downcast::().ok(); + let segment = segment.downcast::().ok(); + if self.segment.is_some() + && self.segment != segment + && let Some(Sink::Media(media)) = self.track.as_mut() + { + media.discontinuity = true; + } + self.segment = segment; self.state = PadState::Active; } Err(reason) => { @@ -608,13 +635,23 @@ impl Pad { /// Re-anchor on FLUSH. A flushing seek rewinds running time, so the timeline must restart: dropping /// the segment moves the pad to NoSegment (the next SEGMENT is accepted fresh via `prev = None`). The - /// producer is kept (FLUSH is not EOS); the codec's partial-AU reset is a documented follow-up. + /// producer is kept (FLUSH is not EOS); its next frame declares the break and resets partial input. pub fn flush(&mut self) { + self.discontinuity(); self.state = PadState::NoSegment; self.segment = None; self.segment_info = None; } + /// Restart measurement on the next frame while retaining the current segment. + pub fn discontinuity(&mut self) { + if self.segment.is_some() + && let Some(Sink::Media(media)) = self.track.as_mut() + { + media.discontinuity = true; + } + } + /// Maps a buffer PTS to a MoQ timestamp without enforcing frame-level monotonicity: frames arrive in /// decode order and B-frames carry non-monotonic presentation timestamps, so a PTS regression is /// normal reordering. Timeline breaks are caught at the SEGMENT level (the `Invalid` state). @@ -668,7 +705,13 @@ impl Pad { match timestamp { Ok(micros) => { let result: Result<()> = match self.track.as_mut().expect("track present") { - Sink::Media(media) => media.write(&data, micros, now), + Sink::Media(media) => match media.write(&data, micros, now) { + Ok(false) => { + gst::debug!(CAT, "dropping delta frame until the keyframe after a break"); + return Ok(PushOutcome::Dropped); + } + result => result.map(|_| ()), + }, Sink::Text(text) => match std::str::from_utf8(&data) { // A cue with no duration would never be dismissed, so drop it rather than pin it // on screen; the demuxer supplies one for every real subtitle sample. @@ -1631,6 +1674,92 @@ mod tests { assert_eq!(jitter("import"), None, "an import's arrival never reaches the catalog"); } + #[test] + fn encoder_seek_does_not_measure_the_pause_as_jitter() { + gst::init().unwrap(); + for boundary in ["segment", "flush", "pause"] { + let (broadcast, catalog) = producers(); + let mut pad = Pad::new(); + pad.observe_caps( + &broadcast, + &catalog, + producer_options(&h264_caps(), Some("encoder")).with_encoder(true), + ); + let anchor = Instant::now(); + for (pts, arrival) in [(0, 0), (33, 33), (66, 166)] { + pad.observe_segment(time_segment()); + assert_eq!( + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + std::time::Duration::from_millis(arrival) + ) + .unwrap(), + PushOutcome::Published + ); + } + let before = catalog.snapshot().video.renditions["encoder"].jitter; + assert_eq!(before, Some(std::time::Duration::from_millis(100))); + match boundary { + "flush" => pad.flush(), + "pause" => pad.discontinuity(), + _ => {} + } + // Seek in the source while keeping the broadcast media clock moving forward. + for (pts, arrival) in [(30_000, 6_000), (30_033, 6_033)] { + let pts = if boundary == "pause" { + // Pausing preserves the segment while the running clock stops. + pts - 29_000 + } else { + pad.observe_segment(time_segment_at(30_000, 1_000)); + pts + }; + assert_eq!( + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + std::time::Duration::from_millis(arrival) + ) + .unwrap(), + PushOutcome::Published + ); + } + assert_eq!( + catalog.snapshot().video.renditions["encoder"].jitter, + before, + "boundary={boundary}" + ); + } + } + + // A pause resumes mid-GOP: the break closed the group, so deltas drop until the next keyframe + // instead of invalidating the pad. + #[test] + fn video_pause_drops_deltas_until_the_next_keyframe() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + let mut pad = Pad::new(); + pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), Some("video"))); + pad.observe_segment(time_segment()); + let delta = Bytes::from_static(&[0, 0, 0, 1, 0x61, 0xe0, 0x12, 0x34]); + let now = Instant::now(); + let push = |pad: &mut Pad, data: Bytes, pts: u64| { + pad.push_buffer(data, Some(gst::ClockTime::from_mseconds(pts)), None, None, now) + .unwrap() + }; + assert_eq!(push(&mut pad, h264_keyframe_au(), 0), PushOutcome::Published); + assert_eq!(push(&mut pad, delta.clone(), 33), PushOutcome::Published); + pad.discontinuity(); + assert_eq!(push(&mut pad, delta.clone(), 66), PushOutcome::Dropped); + assert_eq!(push(&mut pad, delta.clone(), 100), PushOutcome::Dropped); + assert_eq!(push(&mut pad, h264_keyframe_au(), 133), PushOutcome::Published); + assert_eq!(push(&mut pad, delta, 166), PushOutcome::Published); + } + // Text and opaque tracks carry no codec jitter, so asking them to measure one is a mistake to report // rather than a setting to ignore. #[test] diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index c7cd09b910..cadaadecdb 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -1271,7 +1271,7 @@ mod session_tests { // End the served rendition and the broadcast, which answers for the reserved one. catalog.finish().unwrap(); video.finish().unwrap(); - broadcast.finish(); + broadcast.close(); // No shutdown is sent: the session has to end on the media draining alone. super::RUNTIME diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index f4d5a018fa..10748a9340 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -2293,11 +2293,11 @@ mod tests { // Tear down the publisher mid-group. The track ends abruptly (the cursor drains the // segments it already saw and ends; the still-open live-edge group is NOT finalized, - // since a reset can't vouch that its media is complete), while finishing the broadcast - // ends it cleanly rather than as a failure. (Clean-end finalization of the + // since a reset can't vouch that its media is complete), and the broadcast closes. + // (Clean-end finalization of the // live edge is covered by segments::tests::next_after_walks_finalized_segments.) drop((catalog, media, registration)); - broadcast.finish(); + broadcast.close(); let end = tokio::time::timeout(Duration::from_secs(5), segments.next()) .await diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 5832ae9375..7d30ee5f32 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -139,9 +139,8 @@ impl Media { let mut handle = self.handle.lock().expect("media lock poisoned"); match handle.binding.poll_broadcast(&kio::Waiter::noop()) { Poll::Ready(Ok(broadcast)) if broadcast.is_closed() => { - // The bound sibling ended (a different first hop replaces the publisher with - // Dropped). Origin finish()es that spliced front, so is_finished() cannot - // tell a rival publisher from a clean VOD end. Rows listed for it must not + // The bound sibling ended, and a broadcast end carries no cause, so a rival + // publisher looks the same as a clean VOD end. Rows listed for it must not // be served from the replacement. window.clear(); if let Ok(next) = source.bind(Some(rel)) { diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index 2686362d40..4e15a17b18 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -260,8 +260,8 @@ mod tests { let broadcaster = Broadcaster::new(source, Config::default()) .await .expect("catalog broadcast resolves while announced"); - // Finish the publisher so the resolved broadcast (and the broadcaster) reports closed. - producer.finish(); + // Close the publisher so the resolved broadcast (and the broadcaster) reports closed. + producer.close(); settle().await; broadcaster } @@ -311,6 +311,6 @@ mod tests { let cached = server.inner.broadcasters.lock().unwrap().get("live").cloned(); assert!(cached.is_some_and(|cached| Arc::ptr_eq(&cached, &new))); - new_producer.finish(); + new_producer.close(); } } diff --git a/rs/moq-mux/CLAUDE.md b/rs/moq-mux/AGENTS.md similarity index 100% rename from rs/moq-mux/CLAUDE.md rename to rs/moq-mux/AGENTS.md diff --git a/rs/moq-mux/src/codec/av1/import.rs b/rs/moq-mux/src/codec/av1/import.rs index dac785c759..913e41312b 100644 --- a/rs/moq-mux/src/codec/av1/import.rs +++ b/rs/moq-mux/src/codec/av1/import.rs @@ -224,6 +224,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/flac/import.rs b/rs/moq-mux/src/codec/flac/import.rs index 39d68fa925..a2564adc44 100644 --- a/rs/moq-mux/src/codec/flac/import.rs +++ b/rs/moq-mux/src/codec/flac/import.rs @@ -70,6 +70,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/mp3.rs b/rs/moq-mux/src/codec/mp3.rs index c3cf45ac4e..f1cd26c703 100644 --- a/rs/moq-mux/src/codec/mp3.rs +++ b/rs/moq-mux/src/codec/mp3.rs @@ -158,6 +158,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/vp8/import.rs b/rs/moq-mux/src/codec/vp8/import.rs index 7eabb0a2f3..01837a89b9 100644 --- a/rs/moq-mux/src/codec/vp8/import.rs +++ b/rs/moq-mux/src/codec/vp8/import.rs @@ -141,6 +141,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/vp9/import.rs b/rs/moq-mux/src/codec/vp9/import.rs index 71f64b6433..8369e14c3e 100644 --- a/rs/moq-mux/src/codec/vp9/import.rs +++ b/rs/moq-mux/src/codec/vp9/import.rs @@ -141,6 +141,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 45212b9e46..4430b6d179 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -460,6 +460,44 @@ impl Track { } } + /// Mark a timeline break, clearing partial frames and restarting handoff measurement. + /// + /// Publishes a marker without lowering advertised values; resumed timestamps must continue forward. + pub fn discontinuity(&mut self) -> Result<()> { + self.group_start = None; + match self.kind { + TrackKind::Avc3 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.discontinuity() + } + TrackKind::Avc1 { ref mut import, .. } => import.discontinuity(), + TrackKind::Hev1 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.discontinuity() + } + TrackKind::Hvc1 { ref mut import, .. } => import.discontinuity(), + TrackKind::Av01 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.discontinuity() + } + TrackKind::Vp8(ref mut import) => import.discontinuity(), + TrackKind::Vp9(ref mut import) => import.discontinuity(), + TrackKind::Aac(ref mut import) => import.discontinuity(), + TrackKind::Opus(ref mut import) => import.discontinuity(), + TrackKind::Mp3(ref mut import) => import.discontinuity(), + TrackKind::Flac(ref mut import) => import.discontinuity(), + } + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> Result<()> { self.group_start = None; @@ -990,6 +1028,35 @@ mod tests { assert_eq!(collect_groups(subscriber).await, vec![3, 2]); } + #[tokio::test(start_paused = true)] + async fn discontinuity_resets_audio_measurement_and_separates_groups() { + let (mut broadcast, catalog) = new_broadcast(); + let (import, subscriber) = opus_import(&mut broadcast, &catalog); + let mut import: Track = import.into(); + let anchor = std::time::Instant::now(); + for (pts, arrival) in [(0, 0), (20, 120)] { + let timestamp = Timestamp::from_micros(pts * 1_000).unwrap(); + import.decode(b"a", Some(timestamp)).unwrap(); + import + .flush(timestamp, anchor + Duration::from_millis(arrival)) + .unwrap(); + } + let before = catalog.snapshot().audio.renditions["audio"].jitter; + assert_eq!(before, Some(Duration::from_millis(100))); + import.discontinuity().unwrap(); + for (pts, arrival) in [(100, 6_000), (120, 6_020)] { + let timestamp = Timestamp::from_micros(pts * 1_000).unwrap(); + import.decode(b"b", Some(timestamp)).unwrap(); + import + .flush(timestamp, anchor + Duration::from_millis(arrival)) + .unwrap(); + } + assert_eq!(catalog.snapshot().audio.renditions["audio"].jitter, before); + import.finish().unwrap(); + // The middle group is the established empty-payload discontinuity marker. + assert_eq!(collect_groups(subscriber).await, vec![2, 1, 2]); + } + /// Cutting after every frame is still available, and is what a caller wanting the lowest /// latency does: one group (one QUIC stream) per packet, forwarded without waiting. #[tokio::test(start_paused = true)] diff --git a/rs/moq-net/CLAUDE.md b/rs/moq-net/AGENTS.md similarity index 100% rename from rs/moq-net/CLAUDE.md rename to rs/moq-net/AGENTS.md diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 0fcc82dbac..297c2c1764 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -361,8 +361,8 @@ fn bench_handoff(c: &mut Criterion) { total += started.elapsed(); drop(subscription); - incumbent.finish(); - standby.finish(); + incumbent.close(); + standby.close(); // Wait for the front to close so the next iteration starts a fresh one. resolved.closed().await; } diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index 947fa10727..62485be85b 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -15,6 +15,11 @@ //! `session_join_*` times a new viewer connecting, resolving a broadcast, and //! receiving its latest group, swept over what the relays already announce. //! +//! `session_dash_*` models the moq.pro dash aggregator: one session following +//! every node's per-project stats broadcast, each announced by a peer relay, at +//! one small frame per track per tick. Throughput counts tracks, so a flat +//! elements/s across a sweep means the cost per track stays constant. +//! //! Everything runs on one current-thread runtime, so the only work measured is //! the protocol and model code, never scheduling across threads. //! @@ -41,6 +46,12 @@ const FRAMES: usize = 4; const TRACK: &str = "video"; +/// Lite 06 is what the dash session and cluster peers negotiate today. +const DASH_VERSIONS: [&str; 2] = ["moq-lite-06", "moq-transport-22"]; + +/// A stats frame: a small JSON snapshot. +const DASH_FRAME: usize = 128; + /// Each endpoint gets its own bounded pool, as a relay configures one per /// process: without a byte target, every hop keeps each group for the whole /// expiry window and memory grows with the run length instead of the shape. @@ -151,9 +162,9 @@ fn runtime() -> tokio::runtime::Runtime { .unwrap() } -fn write_group(track: &track::Producer, payload: &Bytes) { +fn write_group(track: &track::Producer, payload: &Bytes, frames: usize) { let mut group = track.append_group().unwrap(); - for _ in 0..FRAMES { + for _ in 0..frames { group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); } group.finish().unwrap(); @@ -248,24 +259,19 @@ impl Cluster { producer } - /// Connect a viewer to `relay` and subscribe it to each of `broadcasts`. - async fn join(&mut self, relay: usize, broadcasts: impl Iterator) -> Viewer { + /// Connect a viewer session to `relay`. + async fn join(&mut self, relay: usize) -> Viewer { let origin = self.origin(CLIENT_CACHE); let mut options = MockConnectOptions::new(self.version); - options.server_publish = Some(self.relays[relay].consume()); + // Hidden included, as the dash's `.stats/` scope sees its subtree. + options.server_publish = Some(self.relays[relay].consume().with_hidden(true)); options.client_subscribe = Some(origin.clone()); let pair = connect_mock(options).await; - let mut subscribers = Vec::new(); - for broadcast in broadcasts { - let broadcast = origin.consume().routed_broadcast(path(broadcast)).await.unwrap(); - subscribers.push(broadcast.track(TRACK).unwrap().subscribe(None).await.unwrap()); - } - Viewer { - subscribers, + subscribers: Vec::new(), _pair: pair, - _origin: origin, + origin, } } } @@ -273,7 +279,18 @@ impl Cluster { struct Viewer { subscribers: Vec, _pair: MockPair, - _origin: origin::Producer, + origin: origin::Producer, +} + +impl Viewer { + /// Resolve `path` and subscribe to each of its `tracks`. + async fn watch(&mut self, path: &str, tracks: &[impl AsRef]) { + let broadcast = self.origin.consume().routed_broadcast(path).await.unwrap(); + for track in tracks { + self.subscribers + .push(broadcast.track(track.as_ref()).unwrap().subscribe(None).await.unwrap()); + } + } } /// A cluster with its viewers attached. @@ -313,8 +330,11 @@ impl Room { } // Offset by one so a viewer lands on a different relay than the // publisher it watches whenever there is more than one. - let relay = (viewer + 1) % shape.relays; - viewers.push(cluster.join(relay, broadcasts.into_iter()).await); + let mut viewer = cluster.join((viewer + 1) % shape.relays).await; + for broadcast in broadcasts { + viewer.watch(&path(broadcast), &[TRACK]).await; + } + viewers.push(viewer); } let mut room = Self { @@ -346,7 +366,7 @@ impl Room { return self.paced_round().await; } for &broadcast in &self.watched { - write_group(&self.cluster.tracks[broadcast], &self.payload); + write_group(&self.cluster.tracks[broadcast], &self.payload, FRAMES); } let mut bytes = 0; for viewer in &mut self.viewers { @@ -429,7 +449,7 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) let cluster = Cluster::new(version, *shape).await; let payload = Bytes::from(vec![0; shape.frame]); for track in &cluster.tracks { - write_group(track, &payload); + write_group(track, &payload, FRAMES); } cluster }) @@ -446,7 +466,8 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) for iter in 0..iters as usize { let broadcast = hosted[iter % hosted.len()]; let start = Instant::now(); - let mut viewer = cluster.join(relay, std::iter::once(broadcast)).await; + let mut viewer = cluster.join(relay).await; + viewer.watch(&path(broadcast), &[TRACK]).await; let bytes = read_group(&mut viewer.subscribers[0]).await; elapsed += start.elapsed(); assert_eq!(bytes, FRAMES * shape.frame); @@ -463,6 +484,131 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) group.finish(); } +/// The moq.pro dash aggregator's load: one session on relay 0 following every +/// node's per-project stats broadcast. Every other relay is a node publishing +/// `.stats//node/` into its own origin, as `moq-relay`'s stats +/// producer does, so relay 0 reaches each one over a peer session. +#[derive(Clone, Copy)] +struct Dash { + /// Relays in the mesh: relay 0 hosts the dash session, the rest are nodes. + relays: usize, + /// Projects with a stats broadcast on every node. + projects: usize, + /// Tracks per stats broadcast. + tracks: usize, +} + +impl Dash { + fn total(&self) -> usize { + (self.relays - 1) * self.projects * self.tracks + } + + fn id(&self, version: &str) -> String { + format!( + "{version}/relays={}/projects={}/tracks={}/total={}", + self.relays, + self.projects, + self.tracks, + self.total() + ) + } +} + +/// A mesh of stats-publishing nodes with the dash session attached. +struct DashRoom { + _cluster: Cluster, + tracks: Vec, + _broadcasts: Vec, + dash: Viewer, + payload: Bytes, +} + +impl DashRoom { + async fn new(version: &str, shape: Dash) -> Self { + assert!(shape.relays > 1, "the dash needs a peer node"); + let mut cluster = Cluster::new( + version, + Shape { + relays: shape.relays, + publishers: 0, + viewers: 0, + ..Shape::BASE + }, + ) + .await; + + let names: Vec<_> = (0..shape.tracks).map(|track| format!("stat{track}")).collect(); + let mut tracks = Vec::new(); + let mut broadcasts = Vec::new(); + let mut paths = Vec::new(); + for node in 1..shape.relays { + for project in 0..shape.projects { + let path = format!(".stats/{project}/node/{node}"); + let broadcast = cluster.relays[node].publish(path.as_str(), Default::default()).unwrap(); + for name in &names { + tracks.push(broadcast.create_track(name.as_str(), None).unwrap()); + } + broadcasts.push(broadcast); + paths.push(path); + } + } + + let mut dash = cluster.join(0).await; + for path in &paths { + dash.watch(path, &names).await; + } + + let mut room = Self { + _cluster: cluster, + tracks, + _broadcasts: broadcasts, + dash, + payload: Bytes::from(vec![0; DASH_FRAME]), + }; + room.round().await; + room + } + + /// One stats tick: every track writes a single-frame group and the dash + /// reads it. + async fn round(&mut self) { + for track in &self.tracks { + write_group(track, &self.payload, 1); + } + let mut bytes = 0; + for subscriber in &mut self.dash.subscribers { + bytes += read_group(subscriber).await; + } + assert_eq!(bytes, self.tracks.len() * DASH_FRAME); + } + + async fn measure(&mut self, iters: u64) -> Duration { + let start = Instant::now(); + for _ in 0..iters { + self.round().await; + } + start.elapsed() + } +} + +fn dash(c: &mut Criterion, name: &str, shapes: impl IntoIterator) { + let rt = runtime(); + let shapes: Vec<_> = shapes.into_iter().collect(); + let mut group = c.benchmark_group(format!("session_dash_{name}")); + group.sample_size(10); + for version in DASH_VERSIONS { + for shape in &shapes { + let mut room = None; + group.throughput(Throughput::Elements(shape.total() as u64)); + group.bench_function(BenchmarkId::from_parameter(shape.id(version)), |b| { + let room = room.get_or_insert_with(|| rt.block_on(DashRoom::new(version, *shape))); + b.iter_custom(|iters| rt.block_on(room.measure(iters))) + }); + } + } + group.finish(); +} + /// Print allocations per viewer-group instead of timing, to compare across revisions. fn allocations() { const ROUNDS: usize = 256; @@ -565,6 +711,47 @@ fn session(c: &mut Criterion) { }), ); join(c, "relays", [1, 2, 8].map(|relays| Shape { relays, ..base })); + + // Production (2026-09): ~34 nodes x >=5 projects x ~24 tracks. + let prod = Dash { + relays: 35, + projects: 5, + tracks: 24, + }; + // More broadcasts at a fixed track count per broadcast. + dash( + c, + "projects", + [1, 4, 16].map(|projects| Dash { + relays: 5, + projects, + ..prod + }), + ); + // More tracks per broadcast at a fixed broadcast count. + dash( + c, + "tracks", + [6, 24, 96].map(|tracks| Dash { + relays: 5, + projects: 4, + tracks, + }), + ); + // More peer sessions carrying the same 32 broadcasts, then the production + // shape. + dash( + c, + "relays", + [2, 3, 5, 9, 17, 33] + .map(|relays| Dash { + relays, + projects: 32 / (relays - 1), + ..prod + }) + .into_iter() + .chain([prod]), + ); } criterion_group!(benches, session); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 794e83f2c9..1ec9428b85 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -389,24 +389,6 @@ impl Drop for Counted { } } -/// How the last source for a path detaches, which decides whether the origin closes -/// the broadcast now or holds it open for a replacement. -/// -/// Only the detach that drops the refcount to zero decides, matching the model's rule -/// for several sources at one path (the front's source selection): an earlier owner that vanished -/// does not outvote the last one still on the path. That keeps two advertisements on -/// one session behaving like the same two on separate sessions, where the model sees -/// two independent sources and the last one out decides. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Detach { - /// The peer retracted the path, or we are rolling back an announce we just made. - /// Nothing is coming back, so close it now. - Graceful, - /// The stream carrying the path went away without retracting it. Abort the - /// source so viewers observe the loss as an error rather than a clean end. - Abrupt, -} - struct BroadcastState { // The route announced into our origin for this namespace, post-charge. route: crate::origin::Route, @@ -418,9 +400,8 @@ struct BroadcastState { // active number of PUBLISH_NAMESPACE messages. count: usize, - // One minted source per requested path under the namespace: finish() on a - // deliberate unannounce, dropping (a dying session) aborts them so viewers - // observe the loss as an error. + // One minted source per requested path under the namespace, each closed + // when its guard drops. sources: HashMap, } @@ -805,16 +786,13 @@ where // ends: a clean close, a decode error, or the peer resetting it. Without this // each namespace keeps its refcount and the source never detaches. // - // Abruptly, including on a clean FIN: closing the stream retracts nothing, since - // the protocol has NAMESPACE_DONE for that. Whatever is still live here outlived - // its channel without being withdrawn, so hold the front open for a reconnect. // This is what moq-lite already does, where the equivalent map is a local whose // guards drop. let res = self .run_namespace_entries(&mut stream, &prefix, &peer, &mut live, &mut landing) .await; for path in live { - let _ = self.stop_announce(path, Detach::Abrupt); + let _ = self.stop_announce(path); } res } @@ -875,7 +853,7 @@ where // leave subscriptions on a path the peer no longer offers. tracing::debug!(%path, "dropping reflected namespace"); if live.remove(&path) { - let _ = self.stop_announce(path, Detach::Graceful); + let _ = self.stop_announce(path); } continue; }; @@ -905,7 +883,7 @@ where let path = prefix.join(&msg.suffix); tracing::debug!(%path, "namespace_done"); if live.remove(&path) { - let _ = self.stop_announce(path, Detach::Graceful); + let _ = self.stop_announce(path); } } _ => { @@ -1040,7 +1018,7 @@ where Ok(_) => { if let Err(err) = self.write_ok(&mut stream, request_id).await { // Local rollback, not a peer unannounce: don't count announce bytes. - let _ = self.stop_announce(path, Detach::Graceful); + let _ = self.stop_announce(path); return Err(err); } } @@ -1064,15 +1042,7 @@ where .await; if attached { - // Ending cleanly IS the retraction here, unlike a NAMESPACE stream: this stream - // carries exactly one advertisement, and withdrawing it is what ends the stream. - // Any other ending left the advertisement standing, so the peer never withdrew - // it and the loss reads as abrupt (an error, not a clean end). - let detach = match res.is_ok() { - true => Detach::Graceful, - false => Detach::Abrupt, - }; - self.stop_announce(path, detach)?; + self.stop_announce(path)?; } res @@ -1184,7 +1154,7 @@ where let Some(advert) = self.route(held.as_ref(), &peer) else { if std::mem::take(attached) { tracing::debug!(%path, "publish_namespace now loops back; detaching"); - let _ = self.stop_announce(path.clone(), Detach::Graceful); + let _ = self.stop_announce(path.clone()); } self.write_ok(stream, msg.request_id).await?; continue; @@ -1453,24 +1423,18 @@ where } } - fn stop_announce(&mut self, path: PathOwned, detach: Detach) -> Result<(), Error> { + /// Release one advertisement of `path`, closing its sources when it was the last. + fn stop_announce(&mut self, path: PathOwned) -> Result<(), Error> { let mut state = self.state.lock(); match state.broadcasts.entry(path.clone()) { Entry::Occupied(mut entry) => { entry.get_mut().count -= 1; if entry.get().count == 0 { - tracing::debug!(route = %self.origin.absolute(&path), ?detach, "unannounced"); - // Dropping the entry retracts the route (its announcement drops). - let removed = entry.remove(); - for (_, source) in removed.sources { - match detach { - Detach::Graceful => source.finish(), - // Dropping the guard aborts the source, so the loss reads - // as an error rather than a clean end. - Detach::Abrupt => {} - } - } + tracing::debug!(route = %self.origin.absolute(&path), "unannounced"); + // Dropping the entry retracts the route (its announcement drops) and + // closes its sources (their guards drop). + entry.remove(); } } Entry::Vacant(_) => return Err(Error::NotFound), @@ -1546,8 +1510,8 @@ where let dynamic = source.dynamic(); request.accept(&source); - // Retain the source so a retraction can finish it. If the route was - // retracted since the accept, finish it here as that retraction would + // Retain the source so a retraction can close it. If the route was + // retracted since the accept, close it here as that retraction would // have, and still serve what it took on: tracks subscribed since carry on. let guard = crate::model::broadcast::SourceGuard::new(source); let retracted = { @@ -1560,9 +1524,7 @@ where None => Some(guard), } }; - if let Some(guard) = retracted { - guard.finish(); - } + drop(retracted); let this = self.clone(); broadcasts.push(async move { @@ -3079,6 +3041,53 @@ mod tests { use super::*; + async fn check_publish_fin(responses: Vec, clean: bool) { + use crate::lite::test_transport::ScriptedSession; + use crate::transport::poll::Session as _; + let mut session = ScriptedSession::eof(responses); + let (_, recv) = session.open_bi().await.unwrap(); + let mut reader = Reader::new(recv, Version::Draft19); + let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19).await; + if clean { + assert_eq!(result.unwrap(), 0); + } else { + assert!(matches!(result, Err(Error::ProtocolViolation))); + } + } + + fn fin_responses(clean: bool) -> Vec { + use crate::coding::Encode; + let mut responses = Vec::new(); + if clean { + ietf::PublishDone::ID.encode(&mut responses, Version::Draft19).unwrap(); + ietf::PublishDone { + request_id: None, + status_code: ietf::PublishDoneStatus::TrackEnded.code(Version::Draft19), + stream_count: 0, + reason_phrase: "done".into(), + } + .encode(&mut responses, Version::Draft19) + .unwrap(); + } + responses + } + + #[tokio::test(start_paused = true)] + async fn bare_fin_requires_publish_done() { + for clean in [false, true] { + check_publish_fin(fin_responses(clean), clean).await; + } + } + + #[tokio::test(start_paused = true)] + #[ignore = "requires Bun; run by just test bare-fin in interop CI"] + async fn bare_fin_interop() { + for clean in [false, true] { + let responses = crate::test_interop::fin("moqt-19", false, clean, fin_responses(clean)); + check_publish_fin(responses, clean).await; + } + } + /// The tokio-backed test runtime. Its transport parameter is phantom, so one /// type serves every fake session in this module. @@ -3741,7 +3750,7 @@ mod tests { // Let the SUBSCRIBE go out, then retract the broadcast before any response. settle().await; - producer.finish(); + producer.close(); settle().await; assert!( @@ -4535,7 +4544,7 @@ mod tests { subscriber.start_announce(path.clone(), advert).unwrap(); settle().await; - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!( routed_now(&consumer, "room/host").is_none(), "an explicit NAMESPACE_DONE must retract the route", @@ -4656,7 +4665,7 @@ mod tests { /// Several advertisements share one refcounted source, so the detach that empties it /// is the one that counts: the broadcast survives the first stop and closes on the - /// last, whatever kind each detach is. + /// last. /// /// That is the model's own rule for several sources at one path (the front's source /// selection), @@ -4677,7 +4686,7 @@ mod tests { settle().await; // One advertisement's stream dies: the other still holds the source. - subscriber.stop_announce(path.clone(), Detach::Abrupt).unwrap(); + subscriber.stop_announce(path.clone()).unwrap(); settle().await; assert!( routed_now(&consumer, "room/host").is_some(), @@ -4685,7 +4694,7 @@ mod tests { ); // The last owner retracts: the broadcast closes with it. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); settle().await; assert!( routed_now(&consumer, "room/host").is_none(), @@ -4752,7 +4761,7 @@ mod tests { // One advertisement, so one unannounce detaches it. If the update had bumped the // refcount, this would leave the route stranded. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4805,7 +4814,7 @@ mod tests { ); // One advertisement, so one unannounce detaches it. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4834,9 +4843,9 @@ mod tests { assert!(routed_now(&consumer, "room/host").is_some()); // Two advertisements, so it takes two unannounces to retract. - subscriber.stop_announce(path.clone(), Detach::Graceful).unwrap(); + subscriber.stop_announce(path.clone()).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4863,9 +4872,9 @@ mod tests { assert!(routed_now(&consumer, "room/host").is_some()); // Two advertisements, so it takes two unannounces to retract. - subscriber.stop_announce(path.clone(), Detach::Graceful).unwrap(); + subscriber.stop_announce(path.clone()).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4902,7 +4911,7 @@ mod tests { ); // That supersedes the advertisement it repeats, so the old route is retired. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!( routed_now(&consumer, "room/host").is_none(), "the superseded route must not stay attached" @@ -5362,7 +5371,7 @@ mod tests { // What the stream's exit path does with whatever it still holds. for path in live { - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); } assert!(routed_now(&consumer, "room/a").is_none(), "room/a leaked a refcount"); diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 0ec7ba1348..732ce41989 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -87,6 +87,8 @@ pub mod path; mod recv; mod setup; mod tail; +#[cfg(test)] +mod test_interop; mod util; mod version; diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c97b501b39..854600586a 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1435,6 +1435,88 @@ mod tests { const VERSION: Version = Version::Lite05; + /// Drive the subscriber with a peer's response bytes followed by FIN. + async fn check_subscription_fin(version: Version, responses: Vec, clean: bool) { + let session = crate::lite::test_transport::ScriptedSession::eof(responses); + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session, + origin, + recv_bandwidth: None, + version, + peer_setup: Default::default(), + peer_hop: None, + cost: None, + going_away: Default::default(), + }); + let serve = TrackServe { + subscriber, + path: Path::new("room").to_owned(), + name: "video".to_string(), + }; + let broadcast = crate::broadcast::Info::new().produce(); + let request = broadcast.reserve_track("video").unwrap(); + let serving = ServeLoop::new(&serve, request, Default::default(), Some(Timescale::default())); + let mut reader = broadcast + .consume() + .track("video") + .unwrap() + .subscribe(None) + .await + .unwrap(); + let mut running = TrackServeRun { + serve, + state: TrackRunState::Serve(serving), + }; + assert!( + kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready(), + "{version:?}: FIN must settle immediately" + ); + if clean { + assert!(reader.recv_group().await.unwrap().is_none()); + } else { + assert!(matches!(reader.recv_group().await, Err(Error::ProtocolViolation))); + } + } + + fn fin_responses(version: Version, started: bool, clean: bool) -> Vec { + let mut responses = Vec::new(); + if started { + lite::SubscribeResponse::Start(lite::SubscribeStart { group: 0 }) + .encode(&mut responses, version) + .unwrap(); + } + if clean { + lite::SubscribeResponse::End(lite::SubscribeEnd { group: 0, streams: 0 }) + .encode(&mut responses, version) + .unwrap(); + } + responses + } + + #[tokio::test(start_paused = true)] + async fn bare_fin_requires_subscribe_end() { + for version in [Version::Lite05, Version::Lite06, Version::Lite07] { + for (started, clean) in [(false, false), (true, false), (false, true)] { + check_subscription_fin(version, fin_responses(version, started, clean), clean).await; + } + } + } + + #[tokio::test(start_paused = true)] + #[ignore = "requires Bun; run by just test bare-fin in interop CI"] + async fn bare_fin_interop() { + for version in [Version::Lite05, Version::Lite06, Version::Lite07] { + for (started, clean) in [(false, false), (true, false), (false, true)] { + let responses = fin_responses(version, started, clean); + let responses = + crate::test_interop::fin(crate::Version::from(version).alpn(), started, clean, responses); + check_subscription_fin(version, responses, clean).await; + } + } + } + /// Removing a subscription both stops delivery and releases the session's handle /// on the producer, so the track (its cached groups, its stats subscription) ends /// rather than outliving the subscription it belonged to. @@ -2785,9 +2867,8 @@ impl Announced { } fn declined(&mut self, path: PathOwned) { - if let Some(Some(route)) = self.routes.insert(path, None) { - route.finish(); - } + // Dropping a replaced route closes its sources. + self.routes.insert(path, None); } /// Record an advertisement before deciding what to do with it. @@ -2798,8 +2879,7 @@ impl Announced { /// at each rejection is what stops the next early return from silently freeing a path /// the peer still holds. /// Only valid on a prefix the peer does not already hold, which the caller establishes - /// with [`Self::contains`]. Overwriting an attached route here would drop its source - /// without finishing it, which is [`Self::declined`]'s job. + /// with [`Self::contains`]. Overwriting an attached route is [`Self::declined`]'s job. fn reserve(&mut self, path: PathOwned) { debug_assert!(!self.routes.contains_key(&path), "reserved a prefix already advertised"); self.routes.insert(path, None); @@ -2810,9 +2890,8 @@ impl Announced { } fn retire(&mut self, path: &PathOwned) { - if let Some(Some(route)) = self.routes.remove(path) { - route.finish(); - } + // Dropping the route closes its sources. + self.routes.remove(path); } /// Serve queued requests on every ready route: mint a source per requested @@ -2864,8 +2943,7 @@ struct AnnouncedRoute { route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. dynamic: crate::origin::Dynamic, - /// One minted source per requested path, finished on a clean retraction and - /// aborted (via drop) when the session dies. + /// One minted source per requested path, each closed when its guard drops. sources: HashMap, /// Whether the GOAWAY drain already re-priced this route. drained: bool, @@ -2889,14 +2967,6 @@ impl AnnouncedRoute { } } - /// The peer deliberately retracted the route: finish the minted sources so - /// their consumers observe a clean end, and retract the announcement. - fn finish(self) { - for (_, source) in self.sources { - source.finish(); - } - } - /// Update the announced route in place (a restart). fn update(&mut self, route: crate::origin::Route) { self.route = route.clone(); @@ -3734,6 +3804,9 @@ impl ServeLoop { continue; } Ok(None) => { + if serve.subscriber.version.has_track_stream() && active.end.is_none() { + return Poll::Ready(ServeEnd::GiveBack(Error::ProtocolViolation)); + } tracing::info!(broadcast = %serve.subscriber.log_path(&serve.path), track = %serve.name, "subscribe complete"); // Upstream FIN'd the subscription: the publisher only FINs // once the track's final sequence is known and every group diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 3c81f2a994..33a1d52bd0 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -32,7 +32,7 @@ impl Version { /// every counted stream is open, in place of SUBSCRIBE_DROP. Added in lite-07. #[allow(clippy::match_like_matches_macro)] pub fn has_stream_count(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, _ => true, @@ -46,7 +46,7 @@ impl Version { /// SUBSCRIBE_START/END, and per-frame timestamp prefixes. #[allow(clippy::match_like_matches_macro)] pub fn has_track_stream(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -58,7 +58,7 @@ impl Version { /// there and must not be sent. #[allow(clippy::match_like_matches_macro)] pub fn has_probe_rtt(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 => false, _ => true, @@ -70,7 +70,7 @@ impl Version { /// setup exchange (Lite01/02) and the no-setup drafts (Lite03/04) don't use it. #[allow(clippy::match_like_matches_macro)] pub fn has_setup_stream(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -83,7 +83,7 @@ impl Version { /// send or accept datagram bodies. #[allow(clippy::match_like_matches_macro)] pub fn has_datagrams(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -94,7 +94,7 @@ impl Version { /// from each announcement's hop chain. Added in lite-05. #[allow(clippy::match_like_matches_macro)] pub fn has_announce_ok(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -105,7 +105,7 @@ impl Version { /// shutdown and migration. Added in lite-04. #[allow(clippy::match_like_matches_macro)] pub fn has_goaway(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 => false, _ => true, @@ -117,7 +117,7 @@ impl Version { /// reference that id instead of repeating the path. Added in lite-06. #[allow(clippy::match_like_matches_macro)] pub fn has_announce_id(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, @@ -148,7 +148,7 @@ impl Version { /// next group before it can resume. #[allow(clippy::match_like_matches_macro)] pub fn has_frame_bounds(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, @@ -177,7 +177,7 @@ impl Version { /// of resolving it from the budget. #[allow(clippy::match_like_matches_macro)] pub(crate) fn resolves_start(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, @@ -188,7 +188,7 @@ impl Version { /// requests decode as not opted in. #[allow(clippy::match_like_matches_macro)] pub fn has_hidden(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, _ => true, @@ -199,7 +199,7 @@ impl Version { /// from a live announcement on the same stream. Added in lite-07. #[allow(clippy::match_like_matches_macro)] pub fn has_announce_compression(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, _ => true, @@ -212,7 +212,7 @@ impl Version { /// on hop count alone, exactly as before. #[allow(clippy::match_like_matches_macro)] pub fn has_route_cost(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index 7cdf0618eb..3b7f90306e 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -2,8 +2,8 @@ //! //! A [Producer] creates tracks on demand: a [Consumer] subscribes by name, and the //! producer either serves a track it already has or is handed a [`track::Request`] to -//! fill. Both handles are refcounted clones of one broadcast, which closes on -//! [`Producer::finish`] or when the last producer drops. +//! fill. Both handles are refcounted clones of one broadcast, which ends on +//! [`Producer::close`] or when the last producer drops. //! //! [Info] is the broadcast's static metadata, fixed for its lifetime. use crate::{cache, stats, track}; @@ -67,7 +67,7 @@ impl Info { /// Consume this [Info] to create a producer that carries its metadata. /// /// Keep the returned [`Producer`] alive for as long as the broadcast should stay - /// available, and end it with [`Producer::finish`]. See the note on [`Producer`]. + /// available, and end it with [`Producer::close`]. See the note on [`Producer`]. pub fn produce(self) -> Producer { Producer::new(self) } @@ -94,12 +94,11 @@ struct BroadcastState { // joined across per-session tracks. `None` for an ordinary broadcast. spliced: Option, - // Set by an explicit `Producer::finish()` or `Producer::abort()` so `Drop` can - // tell a deliberate shutdown apart from a producer dropped by accident. + // Set once the broadcast ends: `Producer::close()`, an abort, or the last + // producer-side handle dropping. Every lookup after it answers `Unroutable`. closing: bool, - // Set only by `Producer::finish()`: the broadcast ended deliberately, as - // opposed to aborting or losing its producer. + // Set only by the deprecated `Producer::finish()`, for `Consumer::is_finished`. finished: bool, // The error passed to `Producer::abort()`, reported by `Consumer::closed`. @@ -185,17 +184,10 @@ impl BroadcastState { /// /// # Lifetime /// -/// **You must keep this producer alive for as long as the broadcast should stay -/// available.** A broadcast lives as long as at least one [`Producer`] exists; -/// children do *not* keep it alive (cloning a [`Consumer`] or holding a -/// [`track::Producer`] does nothing for the broadcast's lifetime). When the last -/// producer goes away every consumer observes [`Error::Dropped`]. -/// -/// End the broadcast with [`Self::finish`] rather than dropping it. Dropping is an -/// easy footgun in garbage-collected bindings (Go, Python, ...), where the handle -/// can be collected the moment it falls out of scope even while you are still -/// publishing, tearing the stream down mid-broadcast. Dropping the last producer -/// without [`Self::finish`] logs a warning. +/// A broadcast lives until [`Self::close`] or until the last [`Producer`] (or +/// [`Dynamic`]) drops, whichever comes first; both end it the same way. Children +/// do *not* keep it alive: cloning a [`Consumer`] or holding a [`track::Producer`] +/// does nothing for the broadcast's lifetime. #[derive(Clone)] pub struct Producer { // Held behind an Arc so each track born from this broadcast can inherit a shared @@ -250,12 +242,11 @@ impl Producer { /// local consumers and peers alike. Call it once the tracks a subscriber needs /// first (a catalog) exist, so the advertisement lands with them in place: /// consumers act on it immediately. The route retracts on [`unannounce`](Self::unannounce), - /// [`finish`](Self::finish), [`abort`](Self::abort), or the last producer - /// dropping. + /// [`close`](Self::close), or the last producer dropping. /// /// Fails with [`Error::Closed`] on a standalone broadcast (one not created - /// through an origin, so there is nothing to announce into) or once the - /// origin's driver has been dropped. + /// through an origin, so there is nothing to announce into), once the broadcast + /// has closed, or once the origin's driver has been dropped. pub fn announce(&self, route: Route) -> Result<(), Error> { let mut announcer = self.alive.announcer.lock(); let announcer = announcer.as_mut().ok_or(Error::Closed)?; @@ -344,7 +335,7 @@ impl Producer { /// /// Subscribers wait on the name until it is accepted, so a reservation the producer /// ends up never filling has to be dropped or rejected. Ending the broadcast - /// ([`Self::finish`] or [`Self::abort`]) resolves whatever is left. + /// resolves whatever is left. pub fn reserve_track(&self, name: impl Into>) -> Result { let request = track::Request::new(self.info.clone(), name).with_stats(self.stats.clone()); self.state.lock().insert_track(request.weak())?; @@ -428,7 +419,11 @@ impl Producer { } } - /// Create a consumer that can subscribe to tracks in this broadcast. + /// Create a consumer of this one publisher's broadcast. + /// + /// A view of this broadcast object, not of its path: a new publisher at the same + /// path is never spliced into it, so it ends when this broadcast does. Go through + /// an origin for a consumer that should not care which publisher serves the path. pub fn consume(&self) -> Consumer { Consumer { info: self.info.clone(), @@ -438,52 +433,28 @@ impl Producer { } } - /// Cleanly finish the broadcast once you are done publishing. - /// - /// Marks the broadcast as deliberately finished so consumers observe a normal - /// end. Prefer this over dropping the producer: an accidental drop (see the note - /// on [`Producer`]) logs a warning, whereas `finish()` is silent. - /// - /// Ends the broadcast outright: consumers observe a normal end immediately and no - /// new tracks are served, whether or not other producer clones are still alive. - /// Existing tracks stay readable so consumers can drain what they already have. + /// End the broadcast for good, whether or not other clones are still alive. /// - /// A name that was reserved, or requested and never taken by a [`Dynamic`], resolves - /// with [`Error::NotFound`]: nothing can fill it now, so its subscribers fail rather - /// than waiting on a [`track::Info`] that is never coming. A request a handler took - /// is left for that handler to answer. + /// Retracts its announcement and local discovery; a later [`Self::announce`] fails + /// with [`Error::Closed`]. Tracks already handed out carry on and end with their + /// own finish or abort. Every later [`Consumer::track`], and every name reserved or + /// still queued for a handler, answers [`Error::Unroutable`]: the same answer an + /// origin gives for a path nobody publishes. A request a [`Dynamic`] already took is + /// left for that handler to answer. /// - /// Borrows rather than consumes, matching [`track::Producer::finish`]. Finishing - /// declares the end, so it must not depend on the caller also surrendering the - /// handle. + /// Dropping the last producer does the same. Closing twice is a no-op. + pub fn close(&self) { + self.alive.close(); + } + + #[doc(hidden)] + #[deprecated(note = "use close(); a broadcast end carries no cause")] pub fn finish(&self) { - { - let mut state = self.state.lock(); - state.closing = true; - state.finished = true; - // A name that was reserved or queued but never served can't arrive now, - // and `Consumer::track` already answers `NotFound` for one asked about after - // this point. Say the same to whoever asked earlier. - state.reject_unserved(Error::NotFound); - } - // Ending the broadcast is what consumers wait on, so signal it here rather - // than leaving it to the last handle drop. - let _ = self.alive.token.close(); - self.alive.retire(); + self.alive.end(true); } - /// Abort the broadcast, ending it for consumers with `err`. - /// - /// Like [`finish`](Self::finish) the end is immediate, whether or not other - /// producer clones are still alive, and existing tracks stay readable so - /// consumers can drain what they already have (an abort does not cascade into - /// the tracks), while a name nothing ever served resolves with `err` the same - /// way [`finish`](Self::finish) resolves it. Unlike a finish, consumers observe - /// `err` from [`Consumer::closed`], so the end reads as a failure rather than a - /// deliberate one. - /// - /// Consumes the producer: an abort is terminal. Errors if the broadcast was - /// already finished or aborted. + #[doc(hidden)] + #[deprecated(note = "use close(); a broadcast end carries no cause")] pub fn abort(self, err: Error) -> Result<(), Error> { { let mut state = self.state.lock(); @@ -507,8 +478,8 @@ impl Producer { } } -/// Ends the broadcast when the last [`Producer`] or [`Dynamic`] drops, closing the -/// liveness channel every [`Consumer`] watches. +/// Ends the broadcast on [`Producer::close`] or when the last [`Producer`] or +/// [`Dynamic`] drops, closing the liveness channel every [`Consumer`] watches. /// /// A refcount rather than a "am I the last one?" check inside `Drop`: that answer is /// a snapshot, and acting on it is exactly what invalidates it. @@ -516,7 +487,7 @@ struct Alive { token: kio::Producer<()>, state: kio::Shared, // The advertisement of the broadcast's exact path, owned here so it retracts - // with the broadcast: on finish, abort, or the last producer-side handle + // with the broadcast: on close, abort, or the last producer-side handle // dropping. `None` for a standalone broadcast. announcer: kio::Lock>, } @@ -537,6 +508,29 @@ impl Alive { } } + /// End the broadcast. See [`Producer::close`]. + fn close(&self) { + self.end(false); + } + + /// End the broadcast, recording the deprecated `finished` flag in the same locked + /// transition that claims the end, so a racing `abort` can't win after it's set. + fn end(&self, finished: bool) { + { + let mut state = self.state.lock(); + if std::mem::replace(&mut state.closing, true) { + return; + } + state.finished = finished; + // A name that was reserved or queued but never served can't arrive now, + // and `Consumer::track` answers `Unroutable` for one asked about after this + // point. Say the same to whoever asked earlier. + state.reject_unserved(Error::Unroutable); + } + let _ = self.token.close(); + self.retire(); + } + /// End the broadcast's advertising for good: retract the standing advertisement /// and drop the announcer, so a later `announce` fails with `Closed`. fn retire(&self) { @@ -549,15 +543,7 @@ impl Alive { impl Drop for Alive { fn drop(&mut self) { - // Warn if the last exit wasn't an explicit finish(), since consumers will - // then see Error::Dropped (classically a GC-collected handle in a language - // binding that tears the stream down mid-publish). - if !self.state.read().closing { - tracing::warn!( - "broadcast::Producer dropped without finish(). Keep the producer alive while publishing, then call finish()." - ); - } - self.retire(); + self.close(); } } @@ -574,36 +560,22 @@ impl Producer { } /// A session-owned handle to a source broadcast created via -/// [`crate::origin::Producer::create_broadcast`]: [`Self::finish`] ends it -/// deliberately, while dropping the guard aborts it as [`Error::Dropped`] (a dead -/// session), so consumers observe the loss as an error. Shared by the lite and -/// IETF subscribers so the drop-vs-finish contract lives in one place. -pub(crate) struct SourceGuard { - // `Option` so `finish` can consume the producer while `Drop` aborts it. - producer: Option, -} +/// [`crate::origin::Producer::create_broadcast`], closing it on drop even while the +/// session's serve machines still hold its [`Dynamic`]. +/// +/// A peer's retraction and a dead session end the source the same way: a broadcast +/// carries no end cause past this hop. Shared by the lite and IETF subscribers. +pub(crate) struct SourceGuard(Producer); impl SourceGuard { pub fn new(producer: Producer) -> Self { - Self { - producer: Some(producer), - } - } - - /// End the source deliberately: the origin detaches it immediately, - /// unannouncing the path if it was the last. - pub fn finish(mut self) { - if let Some(producer) = self.producer.take() { - producer.finish(); - } + Self(producer) } } impl Drop for SourceGuard { fn drop(&mut self) { - if let Some(producer) = self.producer.take() { - let _ = producer.abort(Error::Dropped); - } + self.0.close(); } } @@ -614,6 +586,7 @@ impl Drop for SourceGuard { /// [`track::Request::accept`]s it with a concrete [`track::Info`] or /// [`track::Request::reject`]s it. Dropped when no longer needed; pending requests /// are automatically aborted. +#[derive(Clone)] pub struct Dynamic { info: Arc, // Keeps the broadcast alive while a handler exists (mirrors a producer). @@ -622,31 +595,51 @@ pub struct Dynamic { // Ingress stats scope, applied to the tracks this handler serves. Empty (no-op) // for an untagged broadcast. stats: stats::Scope, + // Declared after `alive` so it drops second: when this was the broadcast's last + // handle, `Alive` has already ended it and answered every queued request + // `Unroutable`, so the handler's own `Dropped` rejection finds nothing left. + _handler: Handler, } -impl Clone for Dynamic { +/// Counts one live [`Dynamic`], rejecting the queued requests when the last one drops. +struct Handler(kio::Shared); + +impl Handler { + fn new(state: kio::Shared) -> Self { + state.lock().requests.add_handler(); + Self(state) + } +} + +impl Clone for Handler { fn clone(&self) -> Self { - // Mirror `new`: count each live handle. Without this, deriving Clone would - // let `Drop` decrement past `new`'s single increment and prematurely flip - // the handler count to zero, causing future `track` calls to return `NotFound`. - self.state.lock().requests.add_handler(); + // Count each live handle, or dropping a clone would flip the handler count to + // zero and future `track` calls would return `NotFound`. + Self::new(self.0.clone()) + } +} - Self { - info: self.info.clone(), - alive: self.alive.clone(), - state: self.state.clone(), - stats: self.stats.clone(), +impl Drop for Handler { + fn drop(&mut self) { + // Decrement and reject under one lock, so a `track` call that saw a live + // handler through the same lock can't slip a request past the rejection. + let mut state = self.0.lock(); + if state.requests.remove_handler() { + // No handlers left to fulfill pending requests; reject them so consumers + // don't block forever on tracks nobody will serve. + for request in state.requests.drain_queued() { + request.reject(Error::Dropped); + } } } } impl Dynamic { fn new(info: Arc, alive: Arc, state: kio::Shared, stats: stats::Scope) -> Self { - state.lock().requests.add_handler(); - Self { info, alive, + _handler: Handler::new(state.clone()), state, stats, } @@ -659,9 +652,8 @@ impl Dynamic { /// Poll for the next consumer-requested track, without blocking. /// - /// Returns [`Error::Closed`] once the broadcast was deliberately ended - /// ([`Producer::finish`] or aborted), so a serving loop knows to stop and - /// release its handle. + /// Returns [`Error::Closed`] once the broadcast has ended, so a serving loop + /// knows to stop and release its handle. pub fn poll_requested_track(&mut self, waiter: &kio::Waiter) -> Poll> { let mut state = ready!(self.state.poll(waiter, |state| { if state.requests.has_queued() || state.closing { @@ -699,15 +691,14 @@ impl Dynamic { } } - /// Block until the broadcast is closed, by [`Producer::finish`], - /// [`Producer::abort`], or every producer dropping, returning the cause. + /// Block until the broadcast ends, by [`Producer::close`] or every producer dropping. + /// + /// Returns [`Error::Dropped`], or the error passed to the deprecated `abort`. pub async fn closed(&self) -> Error { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Poll until the broadcast closes; ready with the cause: the error passed to - /// [`Producer::abort`], or [`Error::Dropped`] for a [`Producer::finish`] or a - /// dropped producer (check [`Consumer::is_finished`] to tell those apart). + /// Poll-based variant of [`Self::closed`]. pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { ready!(self.alive.token.poll_closed(waiter)); Poll::Ready(self.state.read().abort.clone().unwrap_or(Error::Dropped)) @@ -719,21 +710,6 @@ impl Dynamic { } } -impl Drop for Dynamic { - fn drop(&mut self) { - // Decrement and reject under one lock, so a `track` call that saw a live - // handler through the same lock can't slip a request past the rejection. - let mut state = self.state.lock(); - if state.requests.remove_handler() { - // No handlers left to fulfill pending requests; reject them so consumers - // don't block forever on tracks nobody will serve. - for request in state.requests.drain_queued() { - request.reject(Error::Dropped); - } - } - } -} - #[cfg(test)] use futures::FutureExt; @@ -753,6 +729,10 @@ impl Dynamic { } /// Subscribe to arbitrary broadcast/tracks. +/// +/// Its close signal means this broadcast object ended, not that the path went +/// offline: announcements say whether a path is live, and a new publisher may +/// announce the same path again. pub struct Consumer { info: Arc, // Broadcast liveness (read-only): watched for close. @@ -806,6 +786,8 @@ impl Consumer { } /// Get a handle to a track on this broadcast. + /// + /// Fails with [`Error::Unroutable`] once the broadcast has ended. pub fn track(&self, name: &str) -> Result { // Rebind the track to *this* handle's view of the broadcast, so a catalog track // resolves its relative references against the path we were handed out at rather @@ -816,16 +798,17 @@ impl Consumer { } fn track_inner(&self, name: &str) -> Result { - // A closed broadcast (every producer and handler gone) serves nothing. - if self.is_closed() { - return Err(Error::Dropped); - } - let mut state = self.state.lock(); + // An ended broadcast serves nothing new, not even a track it still has: + // the lookup answers what a fresh `request_broadcast` for the path would. + // Tracks already handed out are untouched. + if state.closing { + return Err(Error::Unroutable); + } + // A route-fed broadcast mints spliced logical tracks: they outlive any // session, and a route is asked (via the pending queue) to start serving. - let closing = state.closing; if let Some(spliced) = state.spliced.as_mut() { // An aborted logical track is a verdict from the sources attached at // the time, not a property of the name: a publisher that had not yet @@ -850,11 +833,6 @@ impl Consumer { producer.consume(), )); } - // A deliberately-ended broadcast serves nothing new; nothing drains the - // pending queue once the front is torn down. - if closing { - return Err(Error::NotFound); - } let name: Arc = name.into(); let producer = super::resume::Producer::new(); let consumer = producer.consume(); @@ -882,12 +860,6 @@ impl Consumer { return Ok(pending.consume()); } - // A deliberately-ended broadcast serves nothing new; existing tracks above - // stay readable so consumers can drain the cache. - if state.closing { - return Err(Error::NotFound); - } - // Allocate the name once and share the same Arc across the request, the // requests map, and the FIFO order. The request inherits the broadcast's // cache pool through its `Arc`, same as a producer-created track. @@ -919,32 +891,28 @@ impl Consumer { } } - /// Block until the broadcast is closed, by [`Producer::finish`], - /// [`Producer::abort`], or every producer dropping, and return the cause. + /// Block until the broadcast ends, by [`Producer::close`] or every producer dropping. /// - /// Returns the error passed to [`Producer::abort`], or [`Error::Dropped`] for a - /// [`Producer::finish`] or a dropped producer (check [`Self::is_finished`] to - /// tell those apart). + /// Returns [`Error::Dropped`], or the error passed to the deprecated `abort`. pub async fn closed(&self) -> Error { self.alive.closed().await; self.state.read().abort.clone().unwrap_or(Error::Dropped) } - /// Returns true if every [`Producer`] has been dropped. + /// Returns true once the broadcast has ended. pub fn is_closed(&self) -> bool { self.alive.is_closed() } - /// Whether the broadcast is on its way out: deliberately ended (finish/abort - /// marked, even while handles remain) or already fully closed. The origin's + /// Whether the broadcast has ended, observed under the state lock. The origin's /// dispatcher treats a rejection from such a source as imminent detach rather /// than a strike. pub(crate) fn is_closing(&self) -> bool { - self.is_closed() || self.state.read().closing + self.state.read().closing } - /// Whether the broadcast ended via a deliberate [`Producer::finish`], as opposed - /// to aborting or losing its producer. `false` while the broadcast is still live. + #[doc(hidden)] + #[deprecated(note = "a broadcast end carries no cause")] pub fn is_finished(&self) -> bool { self.state.read().finished } @@ -1188,7 +1156,7 @@ mod test { assert!(!demand.is_used()); // Every producer gone: both edges report the closure. - producer.finish(); + producer.close(); assert!(matches!(demand.used().await, Err(Error::Dropped))); assert!(matches!(demand.unused().await, Err(Error::Dropped))); } @@ -1305,31 +1273,47 @@ mod test { track1c.assert_not_closed(); } - /// `closed()` reports the cause: the abort error, or `Dropped` for a finish or - /// a dropped producer, with `is_finished` telling the latter two apart. + /// `close()` ends the broadcast for every clone at once, and a second close is a no-op. #[tokio::test] - async fn closed_cause() { - // Abort: the error comes through, and it isn't a finish. + async fn close_ends_every_clone() { + let producer = Info::new().produce(); + let clone = producer.clone(); + let consumer = producer.consume(); + + producer.close(); + assert!(matches!(consumer.closed().await, Error::Dropped)); + assert!(matches!(consumer.track("video"), Err(Error::Unroutable))); + assert!(matches!(clone.consume().track("video"), Err(Error::Unroutable))); + + clone.close(); + producer.close(); + } + + /// Dropping the last producer ends the broadcast exactly like `close()`. + #[tokio::test] + async fn drop_ends_like_close() { + let producer = Info::new().produce(); + let consumer = producer.consume(); + drop(producer); + assert!(matches!(consumer.closed().await, Error::Dropped)); + assert!(matches!(consumer.track("video"), Err(Error::Unroutable))); + } + + /// The deprecated end APIs keep their old causes until they are removed. + #[tokio::test] + #[allow(deprecated)] + async fn deprecated_end_causes() { let producer = Info::new().produce(); let consumer = producer.consume(); producer.abort(Error::Timeout).unwrap(); assert!(matches!(consumer.closed().await, Error::Timeout)); assert!(!consumer.is_finished()); - // Finish: a deliberate clean end. let producer = Info::new().produce(); let consumer = producer.consume(); producer.finish(); assert!(matches!(consumer.closed().await, Error::Dropped)); assert!(consumer.is_finished()); - - // Plain drop: neither aborted nor finished. - let producer = Info::new().produce(); - let consumer = producer.consume(); - // Deliberate for the test: exercises the accidental-drop path (warns). - drop(producer); - assert!(matches!(consumer.closed().await, Error::Dropped)); - assert!(!consumer.is_finished()); } #[tokio::test] @@ -1503,24 +1487,25 @@ mod test { } /// A reserved name nobody accepts is the parking case a publisher has to be able to - /// end. Ending the broadcast is where it does: `Consumer::track` already answers - /// `NotFound` for a name asked about after this point, so whoever asked earlier gets + /// end. Ending the broadcast is where it does: `Consumer::track` answers + /// `Unroutable` for a name asked about after this point, so whoever asked earlier gets /// the same answer instead of waiting on info that can never arrive. #[tokio::test] - async fn finish_resolves_a_reserved_name() { + async fn close_resolves_a_reserved_name() { let producer = Info::new().produce(); let consumer = producer.consume(); let _request = producer.reserve_track("track1").unwrap(); let pending = subscribe_pending!(consumer, "track1"); - producer.finish(); - assert!(matches!(pending.await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(pending.await, Err(Error::Unroutable))); } - /// An abort says why the broadcast ended, and an unserved name resolves with that - /// reason rather than a generic failure. + /// The deprecated abort says why the broadcast ended, and an unserved name resolves + /// with that reason. #[tokio::test] + #[allow(deprecated)] async fn abort_resolves_a_reserved_name_with_its_reason() { let producer = Info::new().produce(); let consumer = producer.consume(); @@ -1539,23 +1524,51 @@ mod test { /// A request still queued for a handler is the same parking case reached from the /// consumer side, so it ends the same way. #[tokio::test] - async fn finish_resolves_a_queued_request() { + async fn close_resolves_a_queued_request() { let producer = Info::new().produce(); let dynamic = producer.dynamic(); let consumer = dynamic.consume(); let pending = subscribe_pending!(consumer, "track1"); - producer.finish(); - assert!(matches!(pending.await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(pending.await, Err(Error::Unroutable))); + drop(dynamic); + } + + /// A queued request answers the same when the broadcast ends by its last handle + /// dropping, even when that handle is the `Dynamic` that would have served it. + #[tokio::test] + async fn dropping_the_last_handle_resolves_a_queued_request() { + let dynamic = Info::new().produce().dynamic(); + let consumer = dynamic.consume(); + + let pending = subscribe_pending!(consumer, "track1"); + drop(dynamic); + assert!(matches!(pending.await, Err(Error::Unroutable))); + } + + /// With a producer still alive, losing the last handler is not the broadcast ending: + /// the queued request fails as `Dropped`. + #[tokio::test] + async fn dropping_the_last_handler_resolves_a_queued_request_dropped() { + let producer = Info::new().produce(); + let dynamic = producer.dynamic(); + let consumer = dynamic.consume(); + + let pending = subscribe_pending!(consumer, "track1"); + + drop(dynamic); + assert!(matches!(pending.await, Err(Error::Dropped))); + producer.close(); } /// A request a handler already took is the handler's to answer: it may be in flight /// to a peer, and a retraction does not disturb subscriptions already in flight. /// Whatever the handler decides still reaches the consumer. #[tokio::test] - async fn finish_leaves_a_claimed_request_to_its_handler() { + async fn close_leaves_a_claimed_request_to_its_handler() { let producer = Info::new().produce(); let mut dynamic = producer.dynamic(); let consumer = dynamic.consume(); @@ -1565,14 +1578,14 @@ mod test { let dropped = subscribe_pending!(consumer, "track2"); let abandoned = dynamic.requested_track().await.unwrap(); - producer.finish(); + producer.close(); assert!( accepted.poll_ok(&kio::Waiter::noop()).is_pending(), - "finish rejected a claimed request" + "close rejected a claimed request" ); assert!( dropped.poll_ok(&kio::Waiter::noop()).is_pending(), - "finish rejected a claimed request" + "close rejected a claimed request" ); let _track = request.accept(None); @@ -1583,11 +1596,11 @@ mod test { } /// A reverse fetch can install the track metadata before the live request is - /// accepted, but it does not create a live publisher. Finishing the broadcast + /// accepted, but it does not create a live publisher. Closing the broadcast /// must still reject that name so an arrival-order subscriber does not park on /// backfill that is deliberately absent from its queue. #[tokio::test] - async fn finish_resolves_an_unaccepted_track_with_fetched_info() { + async fn close_resolves_an_unaccepted_track_with_fetched_info() { let producer = Info::new().produce(); let consumer = producer.consume(); @@ -1601,24 +1614,25 @@ mod test { pending_fetch.await.unwrap(); let mut subscriber = track.subscribe(None).await.unwrap(); - producer.finish(); - assert!(matches!(subscriber.recv_group().await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(subscriber.recv_group().await, Err(Error::Unroutable))); let stale = request.accept(None); assert!(stale.append_group().is_err()); } /// Ending the broadcast doesn't cascade into a track someone is publishing: it keeps - /// its cache and its publisher decides when it ends. + /// its cache and its publisher decides when it ends. Only a new lookup is refused. #[tokio::test] - async fn finish_spares_a_served_track() { + async fn close_spares_a_served_track() { let producer = Info::new().produce(); let consumer = producer.consume(); let track = producer.create_track("track1", None).unwrap(); let mut subscriber = consumer.track("track1").unwrap().subscribe(None).await.unwrap(); - producer.finish(); + producer.close(); + assert!(matches!(consumer.track("track1"), Err(Error::Unroutable))); track.append_group().unwrap(); subscriber.assert_group(); @@ -1627,22 +1641,22 @@ mod test { /// The publisher may still be holding the `track::Request` for a name the broadcast /// just gave up on. Accepting it afterwards must not resurrect the track, or a - /// subscriber that was told `NotFound` could be contradicted by a later one. + /// subscriber that was told `Unroutable` could be contradicted by a later one. #[tokio::test] - async fn finish_leaves_a_stale_reservation_inert() { + async fn close_leaves_a_stale_reservation_inert() { let producer = Info::new().produce(); let consumer = producer.consume(); let request = producer.reserve_track("track1").unwrap(); let pending = subscribe_pending!(consumer, "track1"); - producer.finish(); - assert!(matches!(pending.await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(pending.await, Err(Error::Unroutable))); let track = request.accept(None); assert!(track.append_group().is_err()); let mut subscriber = track.subscribe(None); - assert!(matches!(subscriber.recv_group().await, Err(Error::NotFound))); + assert!(matches!(subscriber.recv_group().await, Err(Error::Unroutable))); assert!(consumer.track("track1").is_err()); } @@ -1659,7 +1673,7 @@ mod test { drop(request); assert!(matches!(pending.await, Err(Error::Dropped))); - producer.finish(); + producer.close(); } /// `track::Request::reject` carries its reason the same way, which is what lets a @@ -1674,7 +1688,7 @@ mod test { request.reject(Error::NotFound); assert!(matches!(pending.await, Err(Error::NotFound))); - producer.finish(); + producer.close(); } /// The interleave every unused-driven teardown has to survive: a wire subscriber @@ -1711,7 +1725,7 @@ mod test { assert!(track.abort_unused(Error::Cancel).is_ok()); assert!(matches!(consumer.track("video"), Err(Error::NotFound))); - producer.finish(); + producer.close(); } #[test] @@ -1724,6 +1738,6 @@ mod test { track.clone().abort(Error::Cancel).unwrap(); assert!(!track.is_used()); assert!(track.abort_unused(Error::Cancel).is_ok()); - producer.finish(); + producer.close(); } } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index e485a1e6ab..fd8ec6a325 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1261,11 +1261,8 @@ impl Producer { /// polled. Register a [`broadcast::Producer::dynamic`] handler before /// announcing, so the first consumer finds the tracks it serves. /// - /// End the broadcast with [`broadcast::Producer::finish`]; dropping it - /// without finishing also works, but logs a warning. Either way the path - /// closes once it was the last source; an unfinished drop additionally aborts - /// the spliced tracks with an error, so consumers observe a failure rather - /// than a clean end. + /// End the broadcast with [`broadcast::Producer::close`] or by dropping it; + /// either way the path closes once it was the last source. /// /// Fails with [`Error::Unauthorized`] if `path` is outside the prefixes this /// producer may publish under (after [`scope`](Self::scope)), @@ -1295,7 +1292,7 @@ impl Producer { // The broadcast is a route table entry at its exact path from the start, // hidden from cursors and requests until it announces. The entry lives - // as long as the broadcast: its announcer drops on finish, abort, or the + // as long as the broadcast: its announcer drops on close, abort, or the // last handle. let announcing = Announcing { hop: self.hop, @@ -2338,7 +2335,7 @@ async fn run_front(task: FrontTask) { // subscriptions already in flight): dropping their producers // leaves each reader on the copy it was spliced from, ending // when and as that copy ends. - broadcast.finish(); + broadcast.close(); broadcast.release_spliced(err.clone()); for (_, mut io) in tracks.drain() { // A reader still waiting on its source's answer is in flight @@ -4450,7 +4447,7 @@ mod tests { broadcast.announce(Route::default()).unwrap(); announced.assert_next_active("room/alice"); peer.assert_next_active("room/alice"); - broadcast.finish(); + broadcast.close(); announced.assert_next_ended("room/alice"); peer.assert_next_ended("room/alice"); assert!(matches!(broadcast.announce(Route::default()), Err(Error::Closed))); @@ -6383,9 +6380,9 @@ mod tests { assert!(again.is_clone(&resolved)); // Losing one source keeps the front alive; losing both closes it. - first.finish(); + first.close(); settle(|| consumer.get_broadcast("room/alice").is_some()).await; - second.finish(); + second.close(); settle(|| consumer.get_broadcast("room/alice").is_none()).await; // The path is free again for a fresh broadcast. @@ -6421,7 +6418,7 @@ mod tests { group.finish().unwrap(); track.finish().unwrap(); drop(track); - broadcast.finish(); + broadcast.close(); let mut group = next_group(&mut subscription) .await @@ -6466,7 +6463,7 @@ mod tests { // ANNOUNCE_END overtakes the track's end: the route is retracted, and the front // has acted on it, before the track's last group and end arrive. - source.finish(); + source.close(); drop(server); settle(|| resolved.is_closed()).await; let mut group = track.append_group().unwrap(); @@ -6768,7 +6765,7 @@ mod tests { // The incumbent leaving exhausts the table: the refusal is never retried. drop(track); - first.finish(); + first.close(); assert!(matches!(subscription.recv_group().await, Err(Error::Unsupported))); } @@ -6967,7 +6964,7 @@ mod tests { // Publishing through the nested view lands where the root says. let broadcast = nested.create_broadcast("room/chat/live").unwrap(); assert!(producer.consume().get_broadcast("room/chat/live").is_some()); - broadcast.finish(); + broadcast.close(); } #[test] diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs new file mode 100644 index 0000000000..031113d1a2 --- /dev/null +++ b/rs/moq-net/src/test_interop.rs @@ -0,0 +1,16 @@ +//! Response bytes cross the language boundary; each subscriber runs on a mock transport. + +pub(crate) fn fin(version: &str, started: bool, clean: bool, responses: Vec) -> Vec { + let input = serde_json::json!({ "version": version, "started": started, "clean": clean, "responses": responses }); + let output = std::process::Command::new("bun") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/interop/bare-fin.ts")) + .arg(input.to_string()) + .output() + .expect("Bun must be installed for the bare-FIN interop test"); + assert!( + output.status.success(), + "JS subscriber failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("JS publisher returned response bytes") +} diff --git a/rs/moq-net/tests/broadcast_close.rs b/rs/moq-net/tests/broadcast_close.rs new file mode 100644 index 0000000000..fbe94a1e78 --- /dev/null +++ b/rs/moq-net/tests/broadcast_close.rs @@ -0,0 +1,72 @@ +//! `broadcast::Producer::close()` ends a broadcast the same way for a local consumer +//! and a remote one: the handle closes, every new track lookup answers +//! [`Error::Unroutable`](moq_net::Error::Unroutable), a fresh request for the path +//! answers the same, and the broadcast can never be announced again. + +mod support; + +use std::time::Duration; + +use moq_net::{Error, Hop, Version}; +use support::harness::{MockConnectOptions, connect_mock}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +fn produce_origin(hop: u64) -> moq_net::origin::Producer { + let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Close a published broadcast and check what `reader`'s origin answers afterwards. +async fn close_then_lookup(publisher: moq_net::origin::Producer, reader: moq_net::origin::Producer) { + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let _track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let consumer = reader.consume(); + tokio::time::timeout(TIMEOUT, consumer.routed("bcast")) + .await + .expect("announce timeout") + .expect("routed"); + let handle = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("bcast")) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + handle.track("video").expect("a live broadcast serves its track"); + + broadcast.close(); + + tokio::time::timeout(TIMEOUT, handle.closed()) + .await + .expect("the handle never closed"); + assert!(matches!(handle.track("video"), Err(Error::Unroutable))); + assert!(matches!(handle.track("audio"), Err(Error::Unroutable))); + assert!(matches!( + consumer.request_broadcast("bcast").await, + Err(Error::Unroutable) + )); + assert!(matches!(broadcast.announce(Default::default()), Err(Error::Closed))); +} + +#[tokio::test] +async fn close_ends_a_local_consumer() { + tokio::time::pause(); + let origin = produce_origin(1); + close_then_lookup(origin.clone(), origin).await; +} + +#[tokio::test] +async fn close_ends_a_remote_consumer() { + tokio::time::pause(); + for version in ["moq-lite-05", "moq-transport-14", "moq-transport-19"] { + let publisher = produce_origin(1); + let subscriber = produce_origin(2); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + let _pair = connect_mock(options).await; + + close_then_lookup(publisher, subscriber).await; + } +} diff --git a/rs/moq-net/tests/finished_broadcast_mock.rs b/rs/moq-net/tests/finished_broadcast_mock.rs index 4c987f9317..9f2495055a 100644 --- a/rs/moq-net/tests/finished_broadcast_mock.rs +++ b/rs/moq-net/tests/finished_broadcast_mock.rs @@ -90,7 +90,7 @@ async fn round(finish_broadcast: bool) -> (Vec>, Option) track.finish().unwrap(); drop(track); if finish_broadcast { - broadcast.finish(); + broadcast.close(); } // The session and both origins stay up until the reader is done. diff --git a/rs/moq-relay/CLAUDE.md b/rs/moq-relay/AGENTS.md similarity index 100% rename from rs/moq-relay/CLAUDE.md rename to rs/moq-relay/AGENTS.md diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 10cb789776..9481787a99 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -278,7 +278,7 @@ impl Lease { /// revoked, or was re-checked into one that no longer covers the token. /// /// A changed root or a narrower grant ends it: origin handles cannot yet narrow - /// a live scope in place (tracked by `quest/m1/origin-narrowing.md`). A flipped + /// a live scope in place (tracked by `quest/m1/auth/narrowing.md`). A flipped /// `peer` ends it too, since the routes it already announced would be /// misreported as entering here or from a peer. A changed tier keeps the /// session and moves its [stats](Self::with_stats) to the new tier. diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index e3e3bb602e..d6ce31c472 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1627,9 +1627,9 @@ impl Cluster { } } - // Deliberate shutdown: finishing the registration retracts the route. + // Deliberate shutdown: closing the registration retracts the route. if let Some(registration) = self_registration.as_mut() { - registration.finish(); + registration.close(); } Ok(()) } diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 1d7a669529..ac1e3a58c7 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -705,25 +705,28 @@ async fn http_routes_hold_a_lease() { /// An outage keeps the session until `expires`, then closes it as expired. /// -/// Paused: the relay times the lease and its re-checks on tokio's clock, so the -/// minutes below pass virtually. `expires` is wall-clock, so the relay's deadline -/// comes early by the setup's real time, which the slack absorbs. -#[tokio::test(start_paused = true)] +/// On the real clock: a paused one jumps to the next timer whenever the runtime +/// waits on a socket, and macOS delivers loopback asynchronously, so a virtual +/// timeout can fire before the relay answers. Load only delays the close, so +/// asserting it never lands before `expires` holds on a busy machine. +#[tokio::test] async fn an_outage_keeps_the_session_until_expires() { - const EXPIRES: Duration = Duration::from_secs(120); - const SLACK: Duration = Duration::from_secs(30); + // Whole seconds, as the grant crosses the wire, so the relay sees this exact instant. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let expires = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() + 3); - let mut grant = grant(EXPIRES); - grant.revalidate = Some(Duration::from_secs(10)); + let mut grant = grant(Duration::ZERO); + grant.expires = Some(expires); let script = Script::new(grant); - // Down from the start: a re-check that succeeded would restart the deadline - // at whatever the virtual clock had reached. script.on_revalidate(Answer::Status(503)); let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; let (pub_session, sub_session) = connect_and_round_trip(&room_url("tcp", port)).await; - // Through every failed re-check up to just short of expires, the session is up... - tokio::time::sleep(EXPIRES - SLACK).await; + assert_closed(pub_session, TIMEOUT, "publisher").await; + assert!( + SystemTime::now() >= expires, + "an outage must not close the publisher before expires" + ); let outages = script .seen .lock() @@ -732,16 +735,7 @@ async fn an_outage_keeps_the_session_until_expires() { .filter(|r| r.event == Event::Revalidate) .count(); assert!(outages > 0, "no re-check reached the server during the outage"); - assert!( - tokio::time::timeout(Duration::from_millis(100), pub_session.closed()) - .await - .is_err(), - "an outage must not close the publisher before expires" - ); - - // ...and it closes once the grant expires, not later. - assert_closed(pub_session, SLACK, "publisher").await; - assert_closed(sub_session, SLACK, "subscriber").await; + assert_closed(sub_session, TIMEOUT, "subscriber").await; let ends = tokio::time::timeout(TIMEOUT, async { loop { diff --git a/rs/moq-rtc/src/server/mod.rs b/rs/moq-rtc/src/server/mod.rs index 18e0df6831..580b22f671 100644 --- a/rs/moq-rtc/src/server/mod.rs +++ b/rs/moq-rtc/src/server/mod.rs @@ -88,10 +88,10 @@ impl AcceptedSession { } _ = cancel => { tracing::debug!(role = self.role, "webrtc session terminated by DELETE"); - // A deliberate end: finish the broadcast so the origin + // A deliberate end: close the broadcast so the origin // unannounces it immediately. if let Some(broadcast) = self.broadcast.take() { - broadcast.finish(); + broadcast.close(); } Ok(()) } diff --git a/rs/moq-rtmp/src/dial.rs b/rs/moq-rtmp/src/dial.rs index 4e987e63e1..1a0572b5ad 100644 --- a/rs/moq-rtmp/src/dial.rs +++ b/rs/moq-rtmp/src/dial.rs @@ -485,11 +485,11 @@ async fn client_handshake(stream: &mut S) -> anyhow::Result> /// An active pull: the moq-mux FLV importer publishing into the origin. Mirrors the /// server's publisher; either [`Self::finish`] or dropping it unannounces the -/// path, the former without the dropped-without-finish warning. +/// path. struct Publisher { importer: FlvImport, - // A clone of the importer's producer, so a deliberate end can finish() the - // broadcast (prompt unannounce) even though the importer owns it. + // A clone of the importer's producer, so an end can close the broadcast + // (prompt unannounce) even though the importer owns it. broadcast: moq_net::broadcast::Producer, } @@ -522,16 +522,17 @@ impl Publisher { fn finish(&mut self) -> anyhow::Result<()> { self.importer.finish()?; - self.broadcast.finish(); + self.broadcast.close(); Ok(()) } /// Abort the published tracks with `err` so subscribers see the real cause /// (the remote dropped, a protocol error) rather than a generic `Error::Dropped`. /// - /// Consumes the publisher: the broadcast is done. + /// Consumes the publisher and closes the broadcast. fn abort(self, err: moq_net::Error) { self.importer.abort(err); + self.broadcast.close(); } } diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index 4b9f83c80b..847671df16 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -1301,11 +1301,11 @@ async fn run_handshake(stream: &mut S, peer: SocketAddr) -> anyhow::R /// An active publish: the moq-mux FLV importer, which owns the origin-created /// [`BroadcastProducer`](moq_net::broadcast::Producer) it publishes into. /// Either [`Self::finish`] or dropping it closes the broadcast and unannounces -/// the path, the former without the dropped-without-finish warning. +/// the path. struct Publisher { importer: FlvImport, - // A clone of the importer's producer, so a deliberate end can finish() the - // broadcast (prompt unannounce) even though the importer owns it. + // A clone of the importer's producer, so an end can close the broadcast + // (prompt unannounce) even though the importer owns it. broadcast: moq_net::broadcast::Producer, } @@ -1343,7 +1343,7 @@ impl Publisher { /// the broadcast so the origin unannounces it immediately. fn finish(&mut self) -> anyhow::Result<()> { self.importer.finish()?; - self.broadcast.finish(); + self.broadcast.close(); Ok(()) } @@ -1351,9 +1351,10 @@ impl Publisher { /// (the client disconnected, a protocol error) rather than a generic /// `Error::Dropped` from the importer being dropped. /// - /// Consumes the publisher: the broadcast is done. + /// Consumes the publisher and closes the broadcast. fn abort(self, err: moq_net::Error) { self.importer.abort(err); + self.broadcast.close(); } } diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index 84adaae5bc..d5271b5419 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -21,14 +21,14 @@ use crate::Result; /// transport packets and retains any partial trailing packet internally for the /// next call (the same pattern `moq-cli import ... stdin ts` uses against stdin). /// Either [`Self::finish`] or dropping the publisher ends the broadcast and -/// unannounces the path, the former without the dropped-without-finish warning. +/// unannounces the path. pub struct Publisher { // TS carries undecoded elementary streams (SCTE-35, teletext, DVB AC-3, ...) // verbatim, so the importer uses the `mpegts` catalog extension rather than the // media-only `()`, which would route those PIDs to `Stream::Ignored` and drop them. importer: ts::Import, - // A clone of the importer's producer, so a deliberate end can finish() the - // broadcast (prompt unannounce) even though the importer owns it. + // A clone of the importer's producer, so an end can close the broadcast + // (prompt unannounce) even though the importer owns it. broadcast: moq_net::broadcast::Producer, } @@ -65,16 +65,17 @@ impl Publisher { /// the broadcast so the origin unannounces it immediately. pub fn finish(&mut self) -> Result<()> { self.importer.finish().map_err(moq_mux::Error::from)?; - self.broadcast.finish(); + self.broadcast.close(); Ok(()) } /// Abort the published tracks with `err` so subscribers see the real cause /// (the SRT caller dropped, a demux error) rather than a generic `Error::Dropped`. /// - /// Consumes the publisher: the broadcast is done. + /// Consumes the publisher and closes the broadcast. pub fn abort(self, err: moq_net::Error) { self.importer.abort(err); + self.broadcast.close(); } } diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 35838dfa2b..bec11fc793 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -852,12 +852,11 @@ impl GroupPublisher { self.sessions.adopt_parked(&self.broadcast, &mut self.requested); } - /// Deliberately end the broadcast: finish every pair, then the broadcast - /// itself, so teardown emits no dropped-without-finish warnings. + /// Deliberately end the broadcast: finish every pair, then close the broadcast. fn finish(mut self) { self.traffic.finish(); self.sessions.finish(); - self.broadcast.finish(); + self.broadcast.close(); } } diff --git a/rs/moq-tokio/examples/chat.rs b/rs/moq-tokio/examples/chat.rs index 2e78dc5881..98241f9e5d 100644 --- a/rs/moq-tokio/examples/chat.rs +++ b/rs/moq-tokio/examples/chat.rs @@ -83,10 +83,8 @@ async fn run_broadcast(origin: moq_net::origin::Producer) -> anyhow::Result<()> // Sleep before exiting and closing the broadcast. tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; - // Cleanly close the broadcast so subscribers see a normal end. Dropping the - // producer works too, but closing is explicit (and dropping it by accident - // while still publishing is a common bug this makes obvious). - broadcast.finish(); + // End the broadcast. Dropping the producer does the same; closing is explicit. + broadcast.close(); Ok(()) } diff --git a/rs/moq-tokio/examples/clock.rs b/rs/moq-tokio/examples/clock.rs index 195ccead8a..e0515c0d9a 100644 --- a/rs/moq-tokio/examples/clock.rs +++ b/rs/moq-tokio/examples/clock.rs @@ -84,9 +84,8 @@ async fn main() -> anyhow::Result<()> { _ = clock.run() => Ok(()), }; - // Cleanly close the broadcast on exit so subscribers see a normal end - // rather than Error::Dropped. - broadcast.finish(); + // End the broadcast on exit. + broadcast.close(); result } Command::Subscribe => { diff --git a/rs/moq-tokio/src/origin.rs b/rs/moq-tokio/src/origin.rs index 51de8f3801..adbe53cc30 100644 --- a/rs/moq-tokio/src/origin.rs +++ b/rs/moq-tokio/src/origin.rs @@ -52,7 +52,7 @@ mod tests { assert_eq!(update.prefix.as_str(), "cam"); assert!(active); - broadcast.finish(); + broadcast.close(); let (_, active) = next_update(&mut announced).await.expect("retraction"); assert!(!active); } diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 027810527f..a1a469ba68 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -789,7 +789,7 @@ async fn announce_lifecycle_test(version: &str) { // Unannounce: retracted by announce id on the wire. Dropping the announcement // retracts the route; the broadcast's own end is independent. - second.finish(); + second.close(); let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "room/second"); assert!(!active, "expected retraction"); @@ -804,7 +804,7 @@ async fn announce_lifecycle_test(version: &str) { // Replace the route at "first": retract the original (retiring its announce // id on the wire), then announce the same path again (assigning a fresh id). // Await the retraction first so the events cannot coalesce away. - first.finish(); + first.close(); let (update, active) = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "room/first"); assert!(!active, "expected the replaced retraction"); diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 122591eacf..99e1f1fa84 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -199,7 +199,7 @@ impl Transcoder { tasks.shutdown().await; derived.finish()?; - output.finish(); + output.close(); Ok(()) } } diff --git a/rs/moq-video/DESIGN-native-codecs.md b/rs/moq-video/DESIGN-native-codecs.md index 33217db86f..590d0e815d 100644 --- a/rs/moq-video/DESIGN-native-codecs.md +++ b/rs/moq-video/DESIGN-native-codecs.md @@ -283,7 +283,7 @@ A backend "fails to open" (driver missing, no device) the same way an ffmpeg hardware). 4. **NVENC backend** on Linux behind its feature. 5. **Capture swap to nokhwa; drop `ffmpeg-next`.** Delete the ffmpeg capture + - scaler, remove the dep from `Cargo.toml`, update \[CLAUDE.md cross-package + scaler, remove the dep from `Cargo.toml`, update \[AGENTS.md cross-package notes], `doc/bin/cli.md`, the `capture` feature wiring in `moq-cli`, and the packaging recipes. After this ffmpeg is gone and the binary is GPU-accelerated on macOS/NVIDIA, software (openh264) elsewhere. diff --git a/rs/quest/Cargo.toml b/rs/quest/Cargo.toml index b0d189c967..0bef17e7ee 100644 --- a/rs/quest/Cargo.toml +++ b/rs/quest/Cargo.toml @@ -6,7 +6,7 @@ rust-version.workspace = true authors = ["Luke Curley"] license = "MIT OR Apache-2.0" publish = false -description = "Validator and readiness gate for the quest tree: the interlinked Markdown plans under quest/, whose contract is quest/CLAUDE.md. Enforces link resolution, the questline index, the heading vocabulary, and an acyclic Required graph, so a structural mistake fails the PR instead of the next reader, reports what blocks a quest so a flow does not reconstruct that by grepping, and names the branches a quest lands on." +description = "Validator and readiness gate for the quest tree: the interlinked Markdown plans under quest/, whose contract is quest/AGENTS.md. Enforces link resolution, the questline index, the heading vocabulary, and an acyclic Required graph, so a structural mistake fails the PR instead of the next reader, reports what blocks a quest so a flow does not reconstruct that by grepping, and names the branches a quest lands on." [[bin]] name = "quest" diff --git a/rs/quest/src/lib.rs b/rs/quest/src/lib.rs index 564dfb983e..2845f241ac 100644 --- a/rs/quest/src/lib.rs +++ b/rs/quest/src/lib.rs @@ -1,4 +1,4 @@ -//! The quest tree, whose contract is quest/CLAUDE.md: structural validation of +//! The quest tree, whose contract is quest/AGENTS.md: structural validation of //! it, whether a given quest can be started, and which branches carry it. //! //! The whole tree is validated on every run, never just the changed files: the @@ -20,9 +20,9 @@ pub use doc::Doc; pub use ready::Blocker; pub use rules::Finding; -/// CLAUDE.md is the contract rather than a quest: not executable work, so +/// AGENTS.md is the contract rather than a quest: not executable work, so /// neither indexed nor validated. -const NOT_QUESTS: [&str; 1] = ["CLAUDE.md"]; +const NOT_QUESTS: [&str; 1] = ["AGENTS.md"]; /// Every quest document under `/quest`, sorted, repository-relative. pub fn collect(root: &Path) -> Result> { @@ -36,7 +36,7 @@ fn walk(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); - // `file_type` does not follow symlinks, so quest/CLAUDE.md is a file + // `file_type` does not follow symlinks, so quest/AGENTS.md is a file // here and a directory symlink can never make this recurse forever. let kind = entry.file_type()?; if kind.is_dir() { diff --git a/rs/quest/src/main.rs b/rs/quest/src/main.rs index 02b2d1cdb2..27135981cd 100644 --- a/rs/quest/src/main.rs +++ b/rs/quest/src/main.rs @@ -5,7 +5,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; /// The quest tree, the interlinked Markdown plans under quest/ whose contract is -/// quest/CLAUDE.md: validate its structure, report what blocks a quest, or name +/// quest/AGENTS.md: validate its structure, report what blocks a quest, or name /// the branches a quest lands on. #[derive(Parser)] #[command(version, about)] @@ -69,7 +69,7 @@ fn main() -> Result { // Blocked is not a verdict on the whole plan: the piece of it // that does not need the blocker is split into its own quest. eprintln!( - "quest: {} is blocked; split any independently landable piece into its own quest (quest/CLAUDE.md, Creation) rather than starting this one as it stands", + "quest: {} is blocked; split any independently landable piece into its own quest (quest/AGENTS.md, Creation) rather than starting this one as it stands", path.display() ); } diff --git a/rs/quest/src/ready.rs b/rs/quest/src/ready.rs index b1576ae444..62ade835ee 100644 --- a/rs/quest/src/ready.rs +++ b/rs/quest/src/ready.rs @@ -73,7 +73,7 @@ pub fn blockers(root: &Path, path: &Path) -> Result> { /// /// A questline is not listed while it still indexes children; a README with /// no `## Quests` left is the line's own remaining work and lists like any -/// other quest. The absence of a `## Required` heading is what quest/CLAUDE.md +/// other quest. The absence of a `## Required` heading is what quest/AGENTS.md /// defines as ready. pub fn quests(root: &Path) -> Result> { let docs = crate::load(root)?; diff --git a/rs/quest/src/rules.rs b/rs/quest/src/rules.rs index 0705747f15..923865ecf2 100644 --- a/rs/quest/src/rules.rs +++ b/rs/quest/src/rules.rs @@ -1,6 +1,6 @@ //! The rules, and the findings they produce. //! -//! The contract is quest/CLAUDE.md. Every rule here is one that has already +//! The contract is quest/AGENTS.md. Every rule here is one that has already //! been broken by hand, and the expensive failure is a rule that stops firing: //! a validator that quietly enforces nothing looks exactly like a clean tree. @@ -276,7 +276,7 @@ fn index<'a>(found: &mut Findings, known: &BTreeSet<&Path>, docs: &'a [Doc]) -> continue; }; // Existence is not enough: the index points readers at work to pick - // up, and quest/CLAUDE.md is a file under quest/ that is not a quest. + // up, and quest/AGENTS.md is a file under quest/ that is not a quest. if !known.contains(child.as_path()) { found.at( &doc.path, @@ -331,7 +331,7 @@ fn index<'a>(found: &mut Findings, known: &BTreeSet<&Path>, docs: &'a [Doc]) -> /// `Required` must be acyclic. A cycle is a set of quests none of which can /// ever start, and walking the links to rule one out is exactly the manual step -/// CLAUDE.md asks of an author before adding a blocker. +/// AGENTS.md asks of an author before adding a blocker. fn cycles(found: &mut Findings, docs: &[Doc], listed: &BTreeMap) { let mut blockers: BTreeMap<&Path, Vec> = BTreeMap::new(); diff --git a/rs/quest/tests/tree.rs b/rs/quest/tests/tree.rs index e1933be9aa..e5577755a3 100644 --- a/rs/quest/tests/tree.rs +++ b/rs/quest/tests/tree.rs @@ -167,16 +167,16 @@ fn dangling_absolute_link() { tree.rejects("link does not resolve: /quest/m0/line/gone.md"); } -/// Relative links escape the tree (CLAUDE.md points at ../CONTRIBUTING.md), so +/// Relative links escape the tree (AGENTS.md points at ../CONTRIBUTING.md), so /// they resolve against the LINKING FILE's directory. The pair of cases pins the /// direction: resolving against the wrong base would flip both verdicts. #[test] fn relative_link_resolves_against_the_linking_file() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); tree.append( "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../CLAUDE.md).\n", + "\n## Plan\n\nSee [the guide](../../../AGENTS.md).\n", ); tree.accepts(); } @@ -184,14 +184,14 @@ fn relative_link_resolves_against_the_linking_file() { #[test] fn relative_link_above_the_repository_root() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); // One `..` too many, which is exactly what a file flattened up a level // keeps: it still renders, and points at nothing. tree.append( "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../../CLAUDE.md).\n", + "\n## Plan\n\nSee [the guide](../../../../AGENTS.md).\n", ); - tree.rejects("link does not resolve: ../../../../CLAUDE.md"); + tree.rejects("link does not resolve: ../../../../AGENTS.md"); } /// Quests reference each other root-absolutely. A relative one renders fine, so @@ -203,7 +203,7 @@ fn relative_link_to_a_quest() { tree.rejects("link to a quest must be root-absolute: two.md (write /quest/m0/line/two.md)"); } -/// Templates inside fenced blocks are illustrations. Flagging CLAUDE.md's own +/// Templates inside fenced blocks are illustrations. Flagging AGENTS.md's own /// example would make this a check everyone learns to skip. #[test] fn fenced_templates_are_not_links() { @@ -359,13 +359,13 @@ fn relative_index_entry() { } /// The index points readers at work to pick up, so a target that merely exists -/// is not enough: quest/CLAUDE.md is a file under quest/ that is not a quest. +/// is not enough: quest/AGENTS.md is a file under quest/ that is not a quest. #[test] fn index_entry_that_is_not_a_quest() { let tree = Tree::new(); - tree.write("quest/CLAUDE.md", "# Contract\n"); - tree.append("quest/README.md", "- [Contract](/quest/CLAUDE.md)\n"); - tree.rejects("lists /quest/CLAUDE.md, which is not a quest document"); + tree.write("quest/AGENTS.md", "# Contract\n"); + tree.append("quest/README.md", "- [Contract](/quest/AGENTS.md)\n"); + tree.rejects("lists /quest/AGENTS.md, which is not a quest document"); } /// The index is a list of entries, not prose that happens to link. @@ -459,7 +459,7 @@ fn required_link_on_a_wrapped_bullet() { } /// The other half of that rule: an external condition with no link at all is the -/// shape CLAUDE.md prescribes, and must stay legal. +/// shape AGENTS.md prescribes, and must stay legal. #[test] fn required_external_condition() { let tree = Tree::new(); @@ -571,28 +571,28 @@ fn blockquoted_required_entry() { #[test] fn repeated_parent_components() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); tree.append( "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../../../CLAUDE.md).\n", + "\n## Plan\n\nSee [the guide](../../../../../AGENTS.md).\n", ); - tree.rejects("link does not resolve: ../../../../../CLAUDE.md"); + tree.rejects("link does not resolve: ../../../../../AGENTS.md"); } /// Escaping the root must fail even when the joined path happens to exist: /// the repository's own directory name (or a sibling worktree) sits beside the -/// root, so `/..//CLAUDE.md` is a real file that readers of the +/// root, so `/..//AGENTS.md` is a real file that readers of the /// repository-relative link can never reach. #[test] fn escaped_link_resolving_beside_the_root() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); let name = tree.path().file_name().unwrap().to_str().unwrap(); tree.append( "quest/m0/line/one.md", - &format!("\n## Plan\n\nSee [the guide](../../../../{name}/CLAUDE.md).\n"), + &format!("\n## Plan\n\nSee [the guide](../../../../{name}/AGENTS.md).\n"), ); - tree.rejects(&format!("link does not resolve: ../../../../{name}/CLAUDE.md")); + tree.rejects(&format!("link does not resolve: ../../../../{name}/AGENTS.md")); } /// A bullet is not an entry. `- TBD` satisfies "the heading has a list" while diff --git a/swift/Sources/Moq/Broadcast.swift b/swift/Sources/Moq/Broadcast.swift index 07cbf7bf35..13e42a4d02 100644 --- a/swift/Sources/Moq/Broadcast.swift +++ b/swift/Sources/Moq/Broadcast.swift @@ -344,8 +344,15 @@ public final class BroadcastProducer: Sendable { try ffi.removeCatalogSection(name: name) } - /// Finish the broadcast, finalizing the catalog stream. + /// End the broadcast for good: retract it and serve no new tracks. + /// + /// Tracks already subscribed carry on to their own end. Closing again is a no-op. + public func close() throws { + try ffi.close() + } + + @available(*, deprecated, renamed: "close", message: "A broadcast end carries no cause.") public func finish() throws { - try ffi.finish() + try ffi.close() } } diff --git a/swift/Sources/Moq/Media.swift b/swift/Sources/Moq/Media.swift index 13ab75acfc..d2494b20cd 100644 --- a/swift/Sources/Moq/Media.swift +++ b/swift/Sources/Moq/Media.swift @@ -136,6 +136,11 @@ public final class MediaProducer: Sendable { try ffi.flush(timestampUs: timestampUs) } + /// Mark a timeline break and restart handoff measurement, preserving advertised jitter. + public func discontinuity() throws { + try ffi.discontinuity() + } + /// Draw a group boundary here. /// /// Audio has no boundary of its own (every packet is independently decodable), so this is the diff --git a/swift/Sources/Moq/Origin.swift b/swift/Sources/Moq/Origin.swift index 6a5f4fe2d8..8c8aec69b6 100644 --- a/swift/Sources/Moq/Origin.swift +++ b/swift/Sources/Moq/Origin.swift @@ -34,10 +34,8 @@ public final class OriginProducer: Sendable { /// /// The broadcast is invisible and unroutable, for this origin's consumers /// and peers alike, until `BroadcastProducer.announce(route:)`. Announce it - /// after populating tracks. `finish()` - /// unpublishes immediately, while releasing the producer without finishing - /// also unpublishes but reads to subscribers as a failure rather than a - /// deliberate end. + /// after populating tracks. `BroadcastProducer.close()` ends it for good; + /// releasing the last handle does the same. public func createBroadcast(path: String) throws -> BroadcastProducer { BroadcastProducer(try ffi.createBroadcast(path: path)) } diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index bb98b11869..4bacceb18f 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -135,7 +135,14 @@ final class SmokeTests: XCTestCase { let track = try broadcast.publishTrack(name: "events") XCTAssertEqual(try track.name, "events") try track.finish() - try broadcast.finish() + try broadcast.close() + } + + func testBroadcastCloseTwiceIsNoop() throws { + let broadcast = try BroadcastProducer() + try broadcast.close() + try broadcast.close() + XCTAssertThrowsError(try broadcast.publishTrack(name: "events")) } func testVideoHintsReachMediaPublishApi() throws { @@ -148,13 +155,13 @@ final class SmokeTests: XCTestCase { ) let media = try broadcast.publishVideo(format: .avc3, hint: hint) try media.finish() - try broadcast.finish() + try broadcast.close() } func testVideoPropertiesUseDefaultedFields() throws { let broadcast = try BroadcastProducer() try broadcast.setVideoProperties(VideoProperties(rotation: 315)) - try broadcast.finish() + try broadcast.close() } func testBroadcastConsumerFetchesCachedGroup() async throws { @@ -199,7 +206,7 @@ final class SmokeTests: XCTestCase { consumer.cancel() try producer.finish() - try broadcast.finish() + try broadcast.close() } func testJsonStreamRoundTrip() async throws { @@ -220,7 +227,7 @@ final class SmokeTests: XCTestCase { consumer.cancel() try producer.finish() - try broadcast.finish() + try broadcast.close() } func testJsonProducersReportDemand() async throws { @@ -244,7 +251,7 @@ final class SmokeTests: XCTestCase { try await snapshotDemand.unused() try await streamDemand.unused() - try broadcast.finish() + try broadcast.close() } func testRawTrackTimestamps() async throws { @@ -270,7 +277,7 @@ final class SmokeTests: XCTestCase { XCTAssertEqual(groupFrame?.timestampUs, 23_456) try track.finish() - try broadcast.finish() + try broadcast.close() } func testReadFrameSkipsEmptyThenPopulatedGroups() async throws { @@ -287,7 +294,7 @@ final class SmokeTests: XCTestCase { XCTAssertEqual(frame?.timestampUs, 2_000) try track.finish() - try broadcast.finish() + try broadcast.close() } func testSparseGroupsAndKnownEnd() throws { @@ -301,7 +308,7 @@ final class SmokeTests: XCTestCase { try track.createGroup(sequence: 4).finish() XCTAssertThrowsError(try track.createGroup(sequence: 5)) try track.finish() - try broadcast.finish() + try broadcast.close() } /// `frameDurationUs` is microseconds so Opus' 2.5 ms frame is expressible at @@ -331,7 +338,7 @@ final class SmokeTests: XCTestCase { XCTFail("2 ms is not an opus frame duration: \(error)") } - try broadcast.finish() + try broadcast.close() } /// A decoded frame owns its picture: it converts to either CPU layout on @@ -379,7 +386,7 @@ final class SmokeTests: XCTestCase { XCTAssertTrue(stride(from: 3, to: packed.count, by: 4).allSatisfy { packed[$0] == 0xFF }) try video.finish() - try broadcast.finish() + try broadcast.close() } func testEncodeAudioWithOpusObject() throws { @@ -399,7 +406,7 @@ final class SmokeTests: XCTestCase { try producer.write(silence) XCTAssertEqual(try producer.name, "mic") try producer.finish() - try broadcast.finish() + try broadcast.close() } // Release the config before finishing: the producer retains what it needs. @@ -413,7 +420,7 @@ final class SmokeTests: XCTestCase { } try producer.write(silence) try producer.finish() - try broadcast.finish() + try broadcast.close() } } } diff --git a/test/interop/README.md b/test/interop/README.md index e41a0d7179..ab969fc1a0 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -76,6 +76,9 @@ just test interop --all # Pick your own axes: just test interop --publishers rust,python --subscribers rust,c,js-native-bun +# Subscription termination: Rust/JS response bytes over in-memory transports. +just test bare-fin + # Negative control: no publisher, every subscriber must time out. just test interop-negative @@ -183,3 +186,12 @@ clients/ `.github/workflows/interop.yml` runs the full matrix nightly (and on demand, and on PRs that touch `test/interop/`). A red cell means a real interop break in the current tree. + +## Subscription termination + +`just test bare-fin` exchanges encoded subscription responses between Rust and +JS, then feeds them into each implementation's subscriber over an in-memory +transport. It checks bare FIN before and after SUBSCRIBE\_START on lite-05/06/07, +and FIN without PUBLISH\_DONE on IETF draft-19. Clean-end controls use the same +path. This tests response interoperability, not network delivery or relay behavior. +The interop workflow runs it alongside the real-transport matrix. diff --git a/test/interop/bare-fin.ts b/test/interop/bare-fin.ts new file mode 100644 index 0000000000..fc899157e1 --- /dev/null +++ b/test/interop/bare-fin.ts @@ -0,0 +1,84 @@ +/** Exchange subscription response bytes with Rust using in-memory transports. */ +import assert from "node:assert/strict"; +import { ProtocolViolation } from "../../js/net/src/error.ts"; +import { randomHop } from "../../js/net/src/hop.ts"; +import { NativeSession } from "../../js/net/src/ietf/adapter.ts"; +import { PublishDone } from "../../js/net/src/ietf/publish.ts"; +import { Subscribe as IetfSubscribe, SubscribeOk } from "../../js/net/src/ietf/subscribe.ts"; +import { Subscriber as IetfSubscriber } from "../../js/net/src/ietf/subscriber.ts"; +import { Version as IetfVersion } from "../../js/net/src/ietf/version.ts"; +import { StreamId } from "../../js/net/src/lite/stream.ts"; +import { encodeSubscribeResponse, Subscribe, SubscribeEnd, SubscribeStart } from "../../js/net/src/lite/subscribe.ts"; +import { Subscriber as LiteSubscriber } from "../../js/net/src/lite/subscriber.ts"; +import { Track, TrackInfo } from "../../js/net/src/lite/track.ts"; +import { Version as LiteVersion } from "../../js/net/src/lite/version.ts"; +import { createMockTransportPair } from "../../js/net/src/mock.ts"; +import * as Path from "../../js/net/src/path.ts"; +import { Stream, Writer } from "../../js/net/src/stream.ts"; +import type { Subscriber as TrackSubscriber } from "../../js/net/src/track.ts"; + +// Stdout is the response-byte channel back to Rust. +console.debug = console.error; +const input: { version: string; started: boolean; clean: boolean; responses: number[] } = JSON.parse(process.argv[2]); +const pair = createMockTransportPair(input.version); +const bytes: number[] = []; +const output = new Writer( + new WritableStream({ + write: (chunk) => { + bytes.push(...chunk); + }, + }), +); +const path = Path.from("room"); +const lite: Record = { + "moq-lite-05": LiteVersion.DRAFT_05, + "moq-lite-06": LiteVersion.DRAFT_06, + "moq-lite-07-wip": LiteVersion.DRAFT_07, +}; +const version = lite[input.version]; +let reader: TrackSubscriber; +let peer: Stream | undefined; +if (version !== undefined) { + const subscriber = new LiteSubscriber(pair.client, version, randomHop()); + reader = subscriber.consume(path).track("video").subscribe(); + const info = await Stream.accept(pair.server); + assert(info); + assert.equal(await info.reader.u53(), StreamId.Track); + await Track.decode(info.reader, version); + await new TrackInfo({}).encode(info.writer, version); + info.close(); + peer = await Stream.accept(pair.server); + assert(peer); + assert.equal(await peer.reader.u53(), StreamId.Subscribe); + await Subscribe.decode(peer.reader, version); + if (input.started) await encodeSubscribeResponse(output, { start: new SubscribeStart(0) }, version); + if (input.clean) await encodeSubscribeResponse(output, { end: new SubscribeEnd(0) }, version); +} else { + assert.equal(input.version, "moqt-19"); + const version = IetfVersion.DRAFT_19; + const subscriber = new IetfSubscriber({ session: new NativeSession(pair.client, version, true) }); + reader = subscriber.consume(path).track("video").subscribe(); + peer = await Stream.accept(pair.server, version); + assert(peer); + assert.equal(await peer.reader.u53(), IetfSubscribe.id); + const request = await IetfSubscribe.decode(peer.reader, version); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ requestId: request.requestId, trackAlias: 0n }).encode(peer.writer, version); + if (input.clean) { + await output.u53(PublishDone.id); + await new PublishDone({ statusCode: 0x2, streamCount: 0n, reasonPhrase: "done" }).encode(output, version); + } +} +// These bytes came from Rust's encoder; only the omitted end message is malformed. +if (input.responses.length) await peer.writer.write(Uint8Array.from(input.responses)); +await peer.writer.close(); +const closed = await reader.closed; +if (input.clean) { + assert.equal(closed, null); + assert.equal(await reader.recvGroup(), undefined); +} else { + assert(closed instanceof ProtocolViolation); + await assert.rejects(reader.recvGroup(), ProtocolViolation); +} +// Rust feeds the JS-encoded bytes into its own subscriber, followed by FIN. +console.log(JSON.stringify(bytes)); diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 713d4e6865..5400c55be4 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -281,10 +281,10 @@ prepare_go() { } # Build libmoq (the C staticlib + cbindgen header) and compile the C subscriber -# against it. cargo writes moq.h to $TARGET_BASE/include and libmoq.a to the -# profile dir. +# against it. cargo writes libmoq.a to the profile dir, and build.rs writes +# moq.h into its OUT_DIR, which only cargo's JSON messages name. prepare_c() { - local cc="${CC:-cc}" header lib os_libs + local cc="${CC:-cc}" out_dir header lib os_libs have "$cc" || { mark_broken c "no C compiler ($cc) on PATH" return @@ -292,12 +292,14 @@ prepare_c() { echo "building c client (workspace libmoq + cc)..." local flag=() [[ "$PROFILE" == "release" ]] && flag=(--release) - if ! (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p libmoq) >"$HARNESS_RUN/c-build.log" 2>&1; then + if ! (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p libmoq --message-format=json-render-diagnostics) >"$HARNESS_RUN/c-build.json" 2>"$HARNESS_RUN/c-build.log"; then mark_broken c "cargo build -p libmoq failed" sed 's/^/ /' "$HARNESS_RUN/c-build.log" >&2 || true return fi - header="$TARGET_BASE/include/moq.h" + out_dir=$(grep '"reason":"build-script-executed"' "$HARNESS_RUN/c-build.json" | grep libmoq | + sed -n 's/.*"out_dir":"\([^"]*\)".*/\1/p' | tail -1) || true + header="$out_dir/include/moq.h" lib="$TARGET_BASE/$PROFILE/libmoq.a" [[ -f "$header" && -f "$lib" ]] || { mark_broken c "libmoq artifacts missing ($header / $lib)" @@ -319,7 +321,7 @@ prepare_c() { esac done <"$native_libs" C_INTEROP="$HARNESS_RUN/c-interop" - if ! "$cc" "$CLIENTS/c/subscribe.c" -I"$TARGET_BASE/include" -L"$TARGET_BASE/$PROFILE" -lmoq "${os_libs[@]}" -o "$C_INTEROP" >"$HARNESS_RUN/c-compile.log" 2>&1; then + if ! "$cc" "$CLIENTS/c/subscribe.c" -I"$out_dir/include" -L"$TARGET_BASE/$PROFILE" -lmoq "${os_libs[@]}" -o "$C_INTEROP" >"$HARNESS_RUN/c-compile.log" 2>&1; then mark_broken c "cc compile failed" sed 's/^/ /' "$HARNESS_RUN/c-compile.log" >&2 || true fi diff --git a/test/justfile b/test/justfile index d7e8592887..af90bcd7ed 100644 --- a/test/justfile +++ b/test/justfile @@ -100,3 +100,7 @@ wasm *args: # Optional publisher retention across Rust/JS sessions through a Rust relay. max-age: cargo nextest run --locked -p moq-tokio --test max_age --run-ignored all + +# Cross-language subscription termination over in-memory transports. +bare-fin: + just rs test -p moq-net --lib bare_fin_interop --run-ignored all