diff --git a/.claude/skills/close b/.claude/skills/close deleted file mode 120000 index 31284b7508..0000000000 --- a/.claude/skills/close +++ /dev/null @@ -1 +0,0 @@ -../shared/close \ No newline at end of file diff --git a/.claude/skills/merge b/.claude/skills/merge deleted file mode 120000 index 4735484baf..0000000000 --- a/.claude/skills/merge +++ /dev/null @@ -1 +0,0 @@ -../shared/merge \ No newline at end of file diff --git a/.claude/skills/plan-issues/SKILL.md b/.claude/skills/plan-issues/SKILL.md deleted file mode 100644 index ea261e9c75..0000000000 --- a/.claude/skills/plan-issues/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: plan-issues -description: Plan quests from open GitHub issues without the quest label. ---- - -Call /plan-quests for repository's open GitHub issues without the `quest` label. -Add the `quest` label to these issues after the PR merges. diff --git a/.claude/skills/plan-issues/agents/openai.yaml b/.claude/skills/plan-issues/agents/openai.yaml deleted file mode 100644 index e52345a27a..0000000000 --- a/.claude/skills/plan-issues/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "plan-issues" - short_description: "Turn unplanned GitHub issues into repository quests" - default_prompt: "Use $plan-issues to invoke $plan-quests for open GitHub issues without the quest label." diff --git a/.claude/skills/plan-quests/SKILL.md b/.claude/skills/plan-quests/SKILL.md deleted file mode 100644 index 8ba5c9ff8e..0000000000 --- a/.claude/skills/plan-quests/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -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/AGENTS.md` completely. - -Interview the user until you reach a shared understanding. - -Work the tree in **rounds**. -The **frontier** is every decision whose prerequisites are already settled. -Ask the whole frontier in one round, interactively if supported. -Select at least one answer as (recommended) and wait for the user's answers (never guess) before the next round. - -Each round the user answers reshapes the tree: settled decisions push the frontier outward and unblock questions that depended on them. -Recompute the frontier and ask the next round. -A question whose answer depends on another question still open in this round belongs to a *later* round, not this one. - -Finding *facts* is your job, never the user's. -When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it. -Don't block on it, ask the rest of the frontier now. -The *decisions* are the user's: put each to them and wait. - -Search other quests and questlines to keep the larger plan consistent. -When the work changes what a user sees (a wire, an API, a flag, a dashboard), ask whether it needs documentation the feature quest cannot carry inline (a new page or guide), and recommend a quest for that; docs a change makes stale stay in that change. -When the frontier disagrees with a settled quest/plan, challenge the user and resolve the conflict. - -Begin the interview by scoping the goal: the observable outcome, why it matters, and its important boundaries and non-goals. -Restate the goal in one sentence and get it confirmed before moving on to implementation decisions. -If the goal contains independently completable outcomes, split them before planning. -Map the implementation plan as a design tree: every material decision branches into the decisions that hang off it. - -The session is done when the frontier is empty. -The result may be one quest or multiple quests and questlines, split based on what can be completed independently. -Prefix each quest title with `[XS]`, `[S]`, `[M]`, `[L]`, or `[XL]`, including implementation, verification, and landing work. -Once complete, create, update, or delete the relevant quests and questlines. -Record each settled decision and its reason in the quest's Plan, so later sessions don't ask it again. -New work joins the milestone matching its priority, at its rank; a questline groups only quests that ship together, and its README holds the work no child owns (the end-to-end test, the docs page). - -When done, commit and create a draft PR following `CONTRIBUTING.md`. -After local checks pass, mark it ready and monitor CI and the automatic reviews. -Address one review round, then stop and report if the next review still has findings. - -Merge the PR when ready. diff --git a/.claude/skills/plan-quests/agents/openai.yaml b/.claude/skills/plan-quests/agents/openai.yaml deleted file mode 100644 index 0aa796e75f..0000000000 --- a/.claude/skills/plan-quests/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "plan-quests" - short_description: "Scope and publish a repository quest" - default_prompt: "Use $plan-quests to scope and publish a repository quest." diff --git a/.claude/skills/quest-audit/SKILL.md b/.claude/skills/quest-audit/SKILL.md new file mode 100644 index 0000000000..8ae2c71b74 --- /dev/null +++ b/.claude/skills/quest-audit/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-audit +description: Audit outstanding quests for disagreements, conflicts, misaligned priorities, and stale plans, then resolve them with /quest-plan. Use when the user invokes /quest-audit or asks to check the quest tree for consistency. +--- + +Run `quest skill audit` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-convert/SKILL.md b/.claude/skills/quest-convert/SKILL.md new file mode 100644 index 0000000000..bb2e8cbeab --- /dev/null +++ b/.claude/skills/quest-convert/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-convert +description: Convert Github issues to quests. +--- + +Run `quest skill convert` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-delete/SKILL.md b/.claude/skills/quest-delete/SKILL.md new file mode 100644 index 0000000000..5d12f0cf09 --- /dev/null +++ b/.claude/skills/quest-delete/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-delete +description: Abandon a quest. +--- + +Run `quest skill delete` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-finish/SKILL.md b/.claude/skills/quest-finish/SKILL.md new file mode 100644 index 0000000000..4583b29d66 --- /dev/null +++ b/.claude/skills/quest-finish/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-finish +description: Decide which open PRs to merge, then merge them in parallel. +--- + +Run `quest skill finish` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-merge/SKILL.md b/.claude/skills/quest-merge/SKILL.md new file mode 100644 index 0000000000..b665c4f904 --- /dev/null +++ b/.claude/skills/quest-merge/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-merge +description: Merge a quest once reviews and CI pass. +--- + +Run `quest skill merge` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-plan/SKILL.md b/.claude/skills/quest-plan/SKILL.md new file mode 100644 index 0000000000..7b0307359f --- /dev/null +++ b/.claude/skills/quest-plan/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-plan +description: Scope and create quests through an interactive interview. Use when the user invokes /quest-plan, asks to plan a quest, or wants unsettled work split into quests. +--- + +Run `quest skill plan` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-spawn/SKILL.md b/.claude/skills/quest-spawn/SKILL.md new file mode 100644 index 0000000000..21a9b33488 --- /dev/null +++ b/.claude/skills/quest-spawn/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-spawn +description: Start multiple quests in parallel. +--- + +Run `quest skill spawn` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/quest-start/SKILL.md b/.claude/skills/quest-start/SKILL.md new file mode 100644 index 0000000000..5164b6753d --- /dev/null +++ b/.claude/skills/quest-start/SKILL.md @@ -0,0 +1,7 @@ +--- +name: quest-start +description: Start work on a quest. +--- + +Run `quest skill start` and follow its output. +If `quest` is not installed, follow https://github.com/kixelated/quest/blob/main/SETUP.md first. diff --git a/.claude/skills/spawn-merge b/.claude/skills/spawn-merge deleted file mode 120000 index 669e016de4..0000000000 --- a/.claude/skills/spawn-merge +++ /dev/null @@ -1 +0,0 @@ -../shared/spawn-merge \ No newline at end of file diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md deleted file mode 100644 index b8e0f44ffa..0000000000 --- a/.claude/skills/spawn-quests/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: spawn-quests -description: Spawn background agents work on quests in parallel. ---- - -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. - -The scope consists of all ready quests that are not claimed. -Use the argument (if provided) to filter to specific quests/questlines. -Main's tree lags its lines: a child finished on its line branch, or on `dev`, still looks ready from `main`. -Judge readiness from each line branch's own quest directory, and treat a quest deleted on `dev` as done. -Inspect any blocked quests, and determine if they can be unblocked. -A line whose `Quests` list has emptied is a ready quest too: finishing it marks the line's PR ready. - -Recommend an action for each quest: /start-quest, /plan-quests, skip, or delete. -Start the quests you'd start with no open question right away. -Interactively prompt the user about the rest, a few per prompt, each with a short summary and your recommendation. - -Spawn a background sub-agent for each /start-quest. -Create a fresh worktree on the base `quest branch` prints, creating that line branch first if it is missing. -Agents share no writable files: each keeps its scratch files in its own worktree's `.scratch/`, and anything you hand every agent goes in its prompt, not a shared file. -Each agent blocks on its own checks and reports back only when done or blocked. -Limit the concurrency to at most N agents in parallel, where N is half the number of physical CPU cores. -Other sessions share this machine: hold new agents while the load average exceeds the core count. - -Report each sub-agent's final status, staying silent on interim notifications, but do not monitor their PRs. -Prompt the user if they want to /plan-quests for any suggested follow-ups. - -Run /plan-quests for any selected quests in the foreground. -Perform any research and monitoring in the background. - -Finally, create a PR for any created/updated quests. diff --git a/.claude/skills/spawn-quests/agents/openai.yaml b/.claude/skills/spawn-quests/agents/openai.yaml deleted file mode 100644 index 84bd50b05b..0000000000 --- a/.claude/skills/spawn-quests/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "spawn-quests" - short_description: "Triage a milestone's quests and spawn agents for the ready ones" - default_prompt: "Use $spawn-quests to triage a scope's quests and spawn agents for the ones worth starting now." diff --git a/.claude/skills/start-quest/SKILL.md b/.claude/skills/start-quest/SKILL.md deleted file mode 100644 index 73d0911e40..0000000000 --- a/.claude/skills/start-quest/SKILL.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: start-quest -description: Start work on a quest. ---- - -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. - -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/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/`. -Never write to or clean up a directory other agents share, such as a session scratchpad. - -When done, summarize any issues encounted, and suggest potential follow-up. -If you're happy with the outcome, switch the draft PR to ready for review. -If you want another set of eyes on it, keep it a draft. diff --git a/.claude/skills/start-quest/agents/openai.yaml b/.claude/skills/start-quest/agents/openai.yaml deleted file mode 100644 index 11280e5c90..0000000000 --- a/.claude/skills/start-quest/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "start-quest" - short_description: "Find and start an unblocked repository quest" - default_prompt: "Use $start-quest to find unblocked repository quests and start my choice." diff --git a/.claude/skills/takeover b/.claude/skills/takeover deleted file mode 120000 index f004a334ef..0000000000 --- a/.claude/skills/takeover +++ /dev/null @@ -1 +0,0 @@ -../shared/takeover \ No newline at end of file diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 902eb34822..572c730101 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -38,8 +38,8 @@ runs: # even though it is kept in sync with the flake. # # Every segment the generated layout carries has to be carried here too: - # the runner pair, so the two architectures the warm job builds cannot - # read each other, and `target`, so changing `github-cache-mode` cannot + # the runner pair, so a job on an x86 runner cannot restore the ARM64 + # store, and `target`, so changing `github-cache-mode` cannot # restore the other payload. The trailing run id rolls the key on every # run, which is what lets a repeated `workflow_dispatch` seed a cold # cache against GitHub's immutable entries. diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index 21fb6e0f22..cdfc9180be 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -18,13 +18,14 @@ on: workflow_run: # Matched by workflow `name:`, not filename. Every workflow that can run # outside a pull request belongs here; the pull-request-only ones (Check, - # macOS, Windows) are omitted so they don't spawn a skipped Alert run on - # every push to every PR. `just gh check` enforces both halves of that. + # Android) are omitted so they don't spawn a skipped Alert run on every + # push to every PR. `just gh check` enforces both halves of that. # - # Swift is here despite also running on pull requests: it warms its own - # Rust cache on `main`, and that push run is nobody's check, so a break in - # it would otherwise go unseen. The cost is a skipped Alert run per Swift - # pull request. + # Swift and Platform are here despite also running on pull requests: Swift + # warms its own Rust cache on `main`, and Platform compiles every merge to + # `main` and `dev`. Those push runs are nobody's check, so a break in them + # would otherwise go unseen. The cost is a skipped Alert run per pull + # request. workflows: - apt-repo - Cache @@ -36,6 +37,7 @@ on: - moq-gst - moq-relay - Nightly + - Platform - release-brew - Release Dart - Release Dart FFI diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 9b8e9b4d85..881d127686 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -74,8 +74,15 @@ jobs: # No Rust cache: this workflow only runs on pull requests, which may not # save one, so it would have nothing to restore. - - name: Install just and cargo-ndk - run: cargo install --locked "just@$JUST_VERSION" "cargo-ndk@$CARGO_NDK_VERSION" + - name: Install cargo-ndk + run: cargo install --locked "cargo-ndk@$CARGO_NDK_VERSION" + + # A checksummed release binary, where `cargo install` would spend ~2 minutes + # building just from source. + - name: Install just + uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: just@${{ env.JUST_VERSION }} - name: Check run: just rs android diff --git a/.github/workflows/cache.yml b/.github/workflows/cache.yml index f6a5e4d433..6e1a129122 100644 --- a/.github/workflows/cache.yml +++ b/.github/workflows/cache.yml @@ -5,8 +5,14 @@ name: Cache # # Actions scopes cache reads to the current branch plus the default branch, so # only caches written here on `main` are reachable from a pull request. This -# workflow is therefore the single trusted writer, with one store per runner -# architecture. The action refuses to publish from a pull request regardless. +# workflow is therefore the single trusted writer. The action refuses to publish +# from a pull request regardless. +# +# The Rust store is ARM64 only, because every job that restores it runs there. +# Each store is about 6 GB against the repository's 10 GB budget, and Actions +# evicts least recently used first, so an x86 store nobody on a pull request +# read was enough to push out the one they all do. The x86 leg still pushes its +# dev shell, which the x86 jobs substitute. # # This runs the unscoped suite rather than the diff-scoped one: the point is to # leave artifacts for whatever a future PR happens to touch, not to check `main` @@ -45,13 +51,17 @@ jobs: strategy: fail-fast: false matrix: - runner: [ubuntu-24.04-arm, ubuntu-latest] + include: + - runner: ubuntu-24.04-arm + rust: true + - runner: ubuntu-latest + rust: false runs-on: ${{ matrix.runner }} timeout-minutes: 60 steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false @@ -96,6 +106,7 @@ jobs: # `workflow_dispatch` above prime a cold store; the action otherwise saves # only on pushes to the default branch. - name: Rust cache + if: matrix.rust uses: ./.github/actions/rust-cache with: save-on-dispatch: true @@ -104,12 +115,13 @@ jobs: # and the test artifacts (codegen + linked test binaries). They share # little, and a PR usually needs both. - name: Check + if: matrix.rust run: nix develop --command just ci check --all env: MOQ_STRICT: 1 - name: Test - if: ${{ !cancelled() }} + if: ${{ matrix.rust && !cancelled() }} run: nix develop --command just ci test --all env: MOQ_STRICT: 1 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4b26c72456..8fa0ed62ea 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -32,11 +32,6 @@ jobs: timeout-minutes: 60 steps: - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main - with: - tool-cache: false - - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -44,6 +39,23 @@ jobs: # Full history so `just ci check` can diff against origin/$GITHUB_BASE_REF. fetch-depth: 0 + # Quests, agent config, and root Markdown compile nothing, so a diff of only + # those skips the disk cleanup and Rust cache that exist for the build. + - name: Scope + id: scope + run: | + base=$(git merge-base "origin/$GITHUB_BASE_REF" HEAD) + files=$(git diff --name-only "$base") + if grep -qvE '^(quest/|\.claude/|[^/]+\.md$)' <<< "$files"; then + echo build=true >> "$GITHUB_OUTPUT" + fi + + - name: Free disk space + if: steps.scope.outputs.build + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main + with: + tool-cache: false + - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: determinate: false @@ -63,6 +75,7 @@ jobs: # scope such an entry to the PR's own branch anyway, where no later PR # could read it while it ate the repository's 10 GB budget. - name: Rust cache + if: steps.scope.outputs.build uses: ./.github/actions/rust-cache # The same recipe a developer runs locally. It diffs against @@ -88,11 +101,6 @@ jobs: timeout-minutes: 60 steps: - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main - with: - tool-cache: false - - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -100,7 +108,26 @@ jobs: # Full history so `just ci test` can diff against origin/$GITHUB_BASE_REF. fetch-depth: 0 - - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + # Same scope as the `check` job. No test covers those files either, so the + # job skips everything and reports green instead of spending a minute of + # setup to run nothing. + - name: Scope + id: scope + run: | + base=$(git merge-base "origin/$GITHUB_BASE_REF" HEAD) + files=$(git diff --name-only "$base") + if grep -qvE '^(quest/|\.claude/|[^/]+\.md$)' <<< "$files"; then + echo build=true >> "$GITHUB_OUTPUT" + fi + + - name: Free disk space + if: steps.scope.outputs.build + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main + with: + tool-cache: false + + - if: steps.scope.outputs.build + uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: determinate: false # Trust the flake's cachix substituter. `nixConfig` in flake.nix is @@ -115,11 +142,13 @@ jobs: # Restore only, same as the `check` job above. - name: Rust cache + if: steps.scope.outputs.build uses: ./.github/actions/rust-cache # NEXTEST_PROFILE picks up the longer hang timeout in .config/nextest.toml; # without it a runner under load could trip the local one. - name: Test + if: steps.scope.outputs.build run: nix develop --command just ci test env: MOQ_STRICT: 1 diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index c39deacdee..fb675f76b0 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -47,7 +47,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false @@ -75,6 +75,9 @@ jobs: uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: cache-on-failure: true + # A pull request's entry is scoped to its own branch, where no later + # run reads it, while it spends the budget the shared store needs. + save-if: ${{ github.event_name != 'pull_request' }} # Playwright's Chromium isn't in the nix devShell. Install it plus its apt # runtime libs (`--with-deps` needs the host package manager) so the @@ -88,6 +91,10 @@ jobs: run: nix develop --command just test max-age shell: bash -leo pipefail {0} + - name: Harness regressions + run: nix develop --command just test harness + shell: bash -leo pipefail {0} + - name: Subscription termination interop run: nix develop --command just test bare-fin shell: bash -leo pipefail {0} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 74a28f4885..08faaad5ac 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -10,10 +10,7 @@ name: Nightly # that a PR gate can't justify for how rarely it catches something. `obs` is # diff-independent because its PR-time counterpart can't be: obs.yml has to # name the paths that can break an external link to libmoq.a, and no list of -# paths covers every one of them (see the comment there). `windows` and `macos` -# are diff-independent because what they catch is a whole target's worth of -# `#[cfg(target_os = ...)]` code that no Linux job compiles at all, and those -# runners are throttled too hard to gate every PR on. +# paths covers every one of them (see the comment there). # # The `release-*` jobs are diff-independent because the release workflows only # ever run on a tag: a change that compiles under plain cargo can still break a @@ -44,7 +41,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false @@ -57,10 +54,10 @@ jobs: with: determinate: false - - name: Rust Cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - cache-on-failure: true + # Restore only, like `tests` below. Saving its own store cost 3.6 GB of the + # repository's 10 GB budget and evicted the one every pull request restores. + - name: Rust cache + uses: ./.github/actions/rust-cache # Runs even if the audit fails: a fresh advisory shouldn't hide a # compile break in the feature permutations. @@ -77,6 +74,30 @@ jobs: nix develop --command bun install --frozen-lockfile nix develop --command bun js/net/bench/broadcasts.ts + # A route above several interest heads lands once per head, so its re-price + # should expose any slope over heads times received routes. + - name: JS origin announce forwarding benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/forward.ts + + # Fails if reading a fragmented frame costs more per byte as the frame grows. + - name: JS stream reader benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/reader.ts + + # Fails if a chunk carrying many small frames costs as much per frame as one + # carrying a single frame, on either the lite or moq-transport group stream, + # which means decoding went back to a wakeup per frame. + - name: JS group frame benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/frames.ts + + # Fails if publishing a group costs more as the track retains more groups, + # which means the latency guard or the cache went back to scanning them all. + - name: JS track retention benchmark + if: ${{ !cancelled() }} + run: nix develop --command bun js/net/bench/track.ts + # Runs each moq-net Criterion routine once, so a bench that panics (an # unparsable version, a shape that deadlocks) fails here instead of on the # next manual run. Timing is not compared; nextest never runs a @@ -86,6 +107,10 @@ jobs: if: ${{ !cancelled() }} run: nix develop --command cargo bench --locked -p moq-net --features fuzz --bench '*' -- --test + - name: BBB rendition alignment + if: ${{ !cancelled() }} + run: nix develop --command just pub check-bbb + - name: Audit if: ${{ !cancelled() }} run: nix develop --command just rs audit @@ -100,7 +125,7 @@ jobs: # cost of surfacing a day late rather than in review. - name: OBS if: ${{ !cancelled() }} - run: nix develop --command just obs ci + run: nix develop .#obs --command just obs ci - name: moq-c C fixtures if: ${{ !cancelled() }} @@ -133,7 +158,7 @@ jobs: recipe: ["rs doctest --workspace", "rs loom", "test drill-sensitivity", "rs uring"] steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false - name: Checkout @@ -236,7 +261,7 @@ jobs: target: [lite, announce, ietf, varint, path, pattern] steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false - name: Checkout @@ -261,80 +286,3 @@ jobs: name: fuzz-${{ matrix.target }} path: rs/moq-net/fuzz/artifacts/ if-no-files-found: ignore - - # `just rs windows` is the only thing that compiles moq-video's Media - # Foundation capture/encode/decode, its D3D11 frames, and every - # `#[cfg(target_os = "windows")]` test module. The Linux gate skips all of it, - # so without this the code compiles for the first time in a tag-triggered - # release build, and the test targets never compile at all. - # - # A separate job rather than a step because it needs a Windows host and the - # runner-native toolchain: Nix doesn't run here. No Swatinem cache either. The - # repository cache budget is 10 GB, cache.yml spends it warming the Linux - # target/ that every pull request restores, and a once-a-day job shouldn't - # evict that to save itself a few minutes. - windows: - name: Windows - runs-on: windows-latest - timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. - - name: Install NASM - shell: pwsh - run: | - choco install nasm -y --no-progress - "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - # Pinned: `--locked` fixes just's own dependencies, not which version of - # just cargo selects. - - name: Install just - shell: bash - run: cargo install --locked just@1.52.0 - - - name: Check - shell: bash - run: just rs windows - - # `just rs macos` is the only thing that compiles moq-video's VideoToolbox - # encode/decode and its ScreenCaptureKit / AVFoundation capture, plus - # moq-audio's ScreenCaptureKit system audio and its TCC permission pre-check. - # The Linux gate skips all of it. moq-video has a release-time backstop, since - # moq-c's `moq-c-v*` tag build compiles it on Apple Silicon, but moq-audio - # has none: moq-c and moq-ffi take it codecs-only, so no release build ever - # compiles its capture backend. - # - # Same shape as `windows` and for the same reasons: a separate job because it - # needs an Apple host and the runner-native toolchain, and no Swatinem cache - # because a once-a-day job shouldn't evict the Linux `target/` that every pull - # request restores. swift.yml already compiles moq-ffi here, so this stays - # scoped to the two crates holding the Apple-gated code. - macos: - name: macOS - runs-on: macos-latest - timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - # Pinned: `--locked` fixes just's own dependencies, not which version of - # just cargo selects. - - name: Install just - run: cargo install --locked just@1.52.0 - - - name: Check - run: just rs macos diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml index 33fe97214f..dd3cacfb16 100644 --- a/.github/workflows/obs.yml +++ b/.github/workflows/obs.yml @@ -5,7 +5,7 @@ name: OBS # multi-hundred-MB obs-deps bundle on macOS and Windows. # # Linux is the platform that needs no bundle at all -- nixpkgs has obs-studio, -# Qt6 and ffmpeg, so the dev shell is the whole dependency set. The plugin is +# Qt6 and ffmpeg, so the `.#obs` shell is the whole dependency set. The plugin is # platform-independent C++ over moq-c's C ABI, so a Linux compile catches the # breakage a macOS developer would otherwise ship uncompiled. # @@ -74,7 +74,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false @@ -102,9 +102,9 @@ jobs: # The same recipe a developer runs on Linux. Warnings are errors here, via # the platform's CI preset. - name: Build - run: nix develop --command just obs ci + run: nix develop .#obs --command just obs ci # The plugin build above already produced the debug libmoq.a, so the # cargo step inside is a no-op and this is just a cc and a run. - name: moq-c C fixtures - run: nix develop --command just rs c-tests + run: nix develop .#obs --command just rs c-tests diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml new file mode 100644 index 0000000000..a5b48af38b --- /dev/null +++ b/.github/workflows/platform.yml @@ -0,0 +1,158 @@ +name: Platform + +# Compiles the workspace on Windows and macOS, the `#[cfg(...)]` code no Linux +# job compiles at all: moq-video's Media Foundation and VideoToolbox backends, +# their capture paths, moq-audio's ScreenCaptureKit capture, and the smaller +# platform branches elsewhere. It also builds the OBS plugin against the +# obs-deps bundle, which obs.yml can't do on Linux. Without this, a change that +# breaks any of them (a moq-net type they consume, say) merges green and +# compiles for the first time in a tag-triggered release build. +# +# Filtered to the Rust workspace and the OBS plugin, rather than to each job's +# exact dependency graph like android.yml: these are the slowest and most +# expensive runners in CI, and a quest or docs PR has nothing for them to build. +# +# Pushes to `main` and `dev` catch the break two individually green PRs make +# once both land, which is what the nightly run of these jobs used to cover. +# `dev` never ran the nightly at all. +# +# Outside Nix, like android.yml: neither runner has it, so each installs the +# runner-native toolchain. rust-toolchain.toml still pins the compiler, and the +# recipes are the same ones a developer runs on that OS. +# +# No Rust cache. The repository's 10 GB budget is spent on the Linux `target/` +# that cache.yml warms for every pull request, and a Windows or macOS store +# would evict it to save these jobs a few minutes. + +permissions: + contents: read + +on: + push: + branches: [main, dev] + paths: + - "rs/**" + - "cpp/**" + - ".cargo/**" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "justfile" + - ".github/workflows/platform.yml" + pull_request: + # `closed` is here only so merging/closing a PR cancels its in-flight run + # via the concurrency group below; the jobs themselves are skipped on close. + types: [opened, synchronize, reopened, closed] + paths: + - "rs/**" + - "cpp/**" + - ".cargo/**" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "justfile" + - ".github/workflows/platform.yml" + +concurrency: + # Keyed by event, so a merged pull request's `closed` run can't cancel the push + # run it triggers. Pull requests key by number rather than ref, since a merged + # PR's `closed` run can report the base branch as its ref instead of the PR's + # merge ref, and then wouldn't cancel the PR's in-flight run. + group: platform-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + JUST_VERSION: 1.52.0 + +jobs: + windows: + name: Windows + if: github.event.action != 'closed' + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. + - name: Install NASM + shell: pwsh + run: | + choco install nasm -y --no-progress + "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + # A checksummed release binary, where `cargo install` would spend ~2 minutes + # building just from source. + - name: Install just + uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: just@${{ env.JUST_VERSION }} + + - name: Check + shell: bash + run: just rs windows + + macos: + name: macOS + if: github.event.action != 'closed' + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Install just + uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: just@${{ env.JUST_VERSION }} + + - name: Check + run: just rs macos + + # The release build of the OBS plugin, minus the published moq-c: build.sh + # without `--moq-c-release` links the in-tree rs/moq-c instead, so a PR is + # checked against its own C ABI. The obs-deps bundle (libobs, Qt6, ffmpeg) + # comes from buildspec.json at configure time, same as a tag build. + obs: + name: OBS (${{ matrix.name }}) + if: github.event.action != 'closed' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: macOS + target: aarch64-apple-darwin + os: macos-latest + - name: Windows + target: x86_64-pc-windows-msvc + # The plugin preset uses the Visual Studio 17 2022 generator. + os: windows-2022 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Build + shell: bash + env: + TARGET: ${{ matrix.target }} + run: ./cpp/obs/build.sh --target "$TARGET" --output "$RUNNER_TEMP/obs" diff --git a/.github/workflows/release-dart-ffi.yml b/.github/workflows/release-dart-ffi.yml index 85f2fff849..0396251ace 100644 --- a/.github/workflows/release-dart-ffi.yml +++ b/.github/workflows/release-dart-ffi.yml @@ -92,10 +92,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - save-if: ${{ github.event_name != 'pull_request' }} + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install cross-compilation tools (Linux ARM64) if: matrix.target == 'aarch64-unknown-linux-gnu' diff --git a/.github/workflows/release-go-ffi.yml b/.github/workflows/release-go-ffi.yml index 33e21c0c02..f7db014cd4 100644 --- a/.github/workflows/release-go-ffi.yml +++ b/.github/workflows/release-go-ffi.yml @@ -83,11 +83,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - # Don't let untrusted PR builds populate caches reused by trusted runs. - save-if: ${{ github.event_name != 'pull_request' }} + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install cross-compilation tools (Linux ARM64) if: matrix.target == 'aarch64-unknown-linux-gnu' @@ -128,11 +125,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 - with: - # Don't let untrusted PR builds populate caches reused by trusted runs. - save-if: ${{ github.event_name != 'pull_request' }} + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install uniffi-bindgen-go run: | diff --git a/.github/workflows/release-kt-ffi.yml b/.github/workflows/release-kt-ffi.yml index 2d72acfbf0..7694fb4b83 100644 --- a/.github/workflows/release-kt-ffi.yml +++ b/.github/workflows/release-kt-ffi.yml @@ -77,8 +77,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Install cross-compilation tools (Linux ARM64) if: matrix.target == 'aarch64-unknown-linux-gnu' @@ -123,8 +123,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Generate bindings env: @@ -166,7 +166,7 @@ jobs: java-version: "17" - name: Set up Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 with: # The action's default adds the legacy `tools` package, which Google # dropped from the SDK repository. Gradle fetches the rest itself. diff --git a/.github/workflows/release-kt-lib.yml b/.github/workflows/release-kt-lib.yml index d135754329..92fcbf6422 100644 --- a/.github/workflows/release-kt-lib.yml +++ b/.github/workflows/release-kt-lib.yml @@ -170,8 +170,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. # Generate the UniFFI Kotlin so the sibling :moq-ffi project compiles. - name: Generate bindings @@ -189,7 +189,7 @@ jobs: java-version: "17" - name: Set up Android SDK - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 with: # The action's default adds the legacy `tools` package, which Google # dropped from the SDK repository. Gradle fetches the rest itself. diff --git a/.github/workflows/release-rs.yml b/.github/workflows/release-rs.yml index 983cf84b2f..bc111c9d81 100644 --- a/.github/workflows/release-rs.yml +++ b/.github/workflows/release-rs.yml @@ -48,7 +48,7 @@ jobs: steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false diff --git a/.github/workflows/release-swift-ffi.yml b/.github/workflows/release-swift-ffi.yml index 658c06b654..bb4c757256 100644 --- a/.github/workflows/release-swift-ffi.yml +++ b/.github/workflows/release-swift-ffi.yml @@ -61,8 +61,8 @@ jobs: with: targets: ${{ matrix.target }} - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Build shell: bash @@ -96,8 +96,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Rust cache - uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + # No Rust cache: its entry would evict the store every pull request + # restores from the 10 GB budget. See cache.yml. - name: Generate bindings env: diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 6cfb329d56..d0c8392de3 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -62,14 +62,14 @@ jobs: wasm: name: WASM if: github.event.action != 'closed' - # x64, not the arm runner the other jobs use: this is the configuration - # interop.yml already launches Playwright's Chromium on. - runs-on: ubuntu-latest + # ARM64 like check.yml, the only architecture cache.yml warms a Rust store + # for; the harness builds the relay natively, which that store covers. + runs-on: ubuntu-24.04-arm timeout-minutes: 60 steps: - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # main with: tool-cache: false diff --git a/.gitignore b/.gitignore index ec8f692718..af3a5c21ac 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ /.claude/tmp/ /.claude/worktrees/ -# Per-worktree agent scratch (PR bodies, logs, notes); see the start-quest skill. +# Per-worktree agent scratch (PR bodies, logs, notes); see the quest-start skill. /.scratch/ # IDE diff --git a/AGENTS.md b/AGENTS.md index b0a5fd95f8..d896defddc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ This file is split into nested `AGENTS.md` files based on the language/situation - 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. +- Unit tests mock time instead of depending on wall-clock timing or sleeps. - 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. @@ -78,6 +79,7 @@ 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. +Write scratch files (PR bodies, logs, notes) to the worktree's gitignored `.scratch/`, never a directory other agents share. Use the Nix dev shell so tooling matches CI. direnv loads it automatically, but if not: `nix develop --command ...`. @@ -89,6 +91,10 @@ just fix # Auto-fix lint/formatting, same scope These diff the branch against its base and only run the affected packages. +When work mentions a quest, run `quest guide` and follow it. +The `quest` binary comes from the kixelated/quest flake input and serves the quest skills; change them upstream and bump the input. +A quest deleted on `dev` is done, even while `main` still lists it. + # Cross-Package Sync | Change in | Also update | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c8135fdb..78529a74cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Commits -PRs are squash-merged, so the PR title becomes the commit subject and the PR description becomes the body in `git log`. +PRs into `main` are squash-merged, so the PR title becomes the commit subject and the PR description becomes the body in `git log`. +PRs into any other branch (`dev`, a questline) use a merge commit, so their history survives until they land. - Use conventional-commit subjects (`feat(watch): ...`, `fix: ...`, `chore: ...`, `docs: ...`) - AI commit attribution goes in a `Co-Authored-By:` trailer, not the commit body. @@ -45,11 +46,15 @@ For each finding: - If you don't agree with it, reply to the finding and move on. - If it's a relatively easy improvement, fix it and push. Update the summary if needed. +Wait for Codex to review the final head before merging. +Merge only on its thumbs up, or once every Codex finding on the PR is fixed or replied to. +Codex skips fork PRs; ask the maintainer to request one. + # Follow-ups If you encounter issues, or findings that are out of scope, create follow-up quests. Focus on the core problem, offering a potential solution only if its obvious. -For non-trivial tasks, file an issue or offer to run `/plan-quests`. +For non-trivial tasks, file an issue or offer to run `/quest-plan`. # Forks @@ -57,3 +62,14 @@ For non-trivial tasks, file an issue or offer to run `/plan-quests`. Its `moq-sync` workflow merges n0-computer/noq weekly as a PR; review it like any other, and `PARENT` names the upstream commit each release includes. A carried change lists its upstream PR, or the reason it has none, in the fork PR. For an advisory against noq or Quinn, compare the pinned release's `PARENT` with the fixing upstream commit, then sync, release the fork, and bump the pin here. + +# Versions + +Releases are cut separately; bump only when asked. Each package's version lives in one place: + +- **Rust**: release-plz owns crate versions and Rust dependency requirements. +- **JavaScript**: `js/*/package.json` packages with a `scripts.release` entry (skip private ones like `@moq/clock`, `@moq/wasm`), plus the matching workspace version in `bun.lock`. +- **Python**: `py/moq-rs/pyproject.toml`, plus the matching `moq-rs` entry in the root `uv.lock`; `py/moq-ffi` follows the `moq-ffi-v*` tag and Rust crate. +- **Swift**: `swift/VERSION`. **Kotlin**: `moq.version` in `kt/gradle.properties`. **Dart**: `version` in `dart/moq/pubspec.yaml`. Their FFI counterparts track the Rust crate. +- **Go**: `go/wrapper/VERSION` holds a human-owned `MAJOR.MINOR` line; CI derives the patch, so only edit it for a breaking API. Leave the placeholder FFI version in `go.mod` alone. +- **OBS**: release builds take their version from the `moq-c-v*` tag that release-plz cuts for the `moq-c` crate (`cpp/obs/build.sh --moq-c-release`), not from any manifest, so there is nothing to bump. diff --git a/Cargo.lock b/Cargo.lock index 48cccd8e41..49dd90fa96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2835,13 +2835,13 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.6" +version = "0.21.8" dependencies = [ "anyhow", "bytes", "derive_more 2.1.1", "hex", - "kio 0.6.0", + "kio 0.6.1", "lazy_static", "moq-json", "moq-mux", @@ -3802,7 +3802,7 @@ dependencies = [ [[package]] name = "kio" -version = "0.6.0" +version = "0.6.1" dependencies = [ "criterion", "futures", @@ -3884,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.6" +version = "0.6.8" [[package]] name = "libredox" @@ -4135,7 +4135,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.6" +version = "0.0.8" dependencies = [ "async-trait", "bytes", @@ -4152,7 +4152,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.5" +version = "0.1.7" dependencies = [ "block2 0.6.2", "bytes", @@ -4160,7 +4160,7 @@ dependencies = [ "dispatch2", "fixed-resample", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-mux", "moq-net", "objc2 0.6.4", @@ -4183,16 +4183,17 @@ dependencies = [ [[package]] name = "moq-auth" -version = "0.1.3" +version = "0.1.5" dependencies = [ "anyhow", "aws-lc-rs", "axum", "base64 0.23.1", "jsonwebtoken", - "kio 0.6.0", + "kio 0.6.1", "moq-auth", "moq-pattern", + "moq-token", "p256", "p384", "rand 0.10.3", @@ -4231,10 +4232,10 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.5" +version = "0.1.7" dependencies = [ "bytes", - "kio 0.6.0", + "kio 0.6.1", "moq-flate", "moq-net", "thiserror 2.0.21", @@ -4243,7 +4244,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.6" +version = "0.5.8" dependencies = [ "anyhow", "boytacean", @@ -4269,7 +4270,7 @@ dependencies = [ "bytes", "cbindgen", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-audio", "moq-json", "moq-loc", @@ -4287,7 +4288,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.6" +version = "0.12.8" dependencies = [ "anyhow", "axum", @@ -4325,14 +4326,14 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.6" +version = "0.0.8" dependencies = [ "aws-lc-rs", "base64 0.23.1", "bytes", "criterion", "hex", - "kio 0.6.0", + "kio 0.6.1", "moq-net", "serde_json", "thiserror 2.0.21", @@ -4344,12 +4345,12 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.6" +version = "0.4.8" dependencies = [ "bytes", "getrandom 0.4.3", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-audio", "moq-json", "moq-mux", @@ -4378,7 +4379,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.6" +version = "0.4.8" dependencies = [ "anyhow", "bytes", @@ -4397,13 +4398,13 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.6" +version = "0.5.8" dependencies = [ "axum", "bytes", "hang", "humantime", - "kio 0.6.0", + "kio 0.6.1", "m3u8-rs", "moq-mux", "moq-net", @@ -4421,12 +4422,12 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.5.3" +version = "0.5.5" dependencies = [ "bytes", "criterion", "json-patch", - "kio 0.6.0", + "kio 0.6.1", "moq-flate", "moq-net", "serde", @@ -4438,7 +4439,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.14" +version = "0.2.16" dependencies = [ "bytes", "moq-net", @@ -4447,7 +4448,7 @@ dependencies = [ [[package]] name = "moq-msf" -version = "0.5.0" +version = "0.5.1" dependencies = [ "serde", "serde_json", @@ -4456,7 +4457,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.6" +version = "0.10.8" dependencies = [ "anyhow", "base64 0.23.1", @@ -4464,7 +4465,7 @@ dependencies = [ "futures", "h264-parser", "hang", - "kio 0.6.0", + "kio 0.6.1", "memchr", "moq-binary", "moq-json", @@ -4493,14 +4494,14 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.5" +version = "0.3.7" dependencies = [ "arrayvec", "bytes", "criterion", "futures", "getrandom 0.4.3", - "kio 0.6.0", + "kio 0.6.1", "loom", "moq-pattern", "num_enum", @@ -4601,7 +4602,7 @@ dependencies = [ [[package]] name = "moq-pattern" -version = "0.1.0" +version = "0.1.1" dependencies = [ "moq-pattern", "serde", @@ -4610,7 +4611,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.6" +version = "0.15.8" dependencies = [ "anyhow", "axum", @@ -4653,9 +4654,9 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.6" +version = "0.2.8" dependencies = [ - "kio 0.6.0", + "kio 0.6.1", "moq-auth", "moq-json", "moq-net", @@ -4667,7 +4668,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.6" +version = "0.3.8" dependencies = [ "aws-lc-rs", "axum", @@ -4687,7 +4688,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.6" +version = "0.3.8" dependencies = [ "anyhow", "byteorder", @@ -4735,7 +4736,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.6" +version = "0.3.8" dependencies = [ "bytes", "futures", @@ -4751,7 +4752,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.6" +version = "0.2.8" dependencies = [ "futures", "moq-json", @@ -4764,9 +4765,27 @@ dependencies = [ "web-async", ] +[[package]] +name = "moq-token" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014ed664b93a4a1bde2547fc92604f3147d2087ed7577e069a33bb88662bb57b" +dependencies = [ + "aws-lc-rs", + "base64 0.23.1", + "jsonwebtoken", + "p256", + "p384", + "rsa", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.21", +] + [[package]] name = "moq-tokio" -version = "0.19.17" +version = "0.19.19" dependencies = [ "anyhow", "bytes", @@ -4819,12 +4838,12 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.5" +version = "0.1.7" dependencies = [ "anyhow", "bytes", "hang", - "kio 0.6.0", + "kio 0.6.1", "moq-mux", "moq-net", "moq-tokio", @@ -4838,14 +4857,14 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.7" +version = "0.0.9" dependencies = [ "anyhow", "bytes", "criterion", "http", "io-uring", - "kio 0.6.0", + "kio 0.6.1", "libc", "moq-net", "moq-noq-proto", @@ -4895,7 +4914,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.5" +version = "0.1.7" dependencies = [ "anyhow", "ash", @@ -4909,7 +4928,7 @@ dependencies = [ "futures", "h264-reader", "hang", - "kio 0.6.0", + "kio 0.6.1", "libc", "libloading 0.9.0", "linux-raw-sys 0.12.1", @@ -4949,6 +4968,7 @@ name = "moq-wasm" version = "0.0.0" dependencies = [ "console_error_panic_hook", + "futures", "getrandom 0.4.3", "js-sys", "moq-net", @@ -6812,17 +6832,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.2", - "memchr", - "unicase", -] - [[package]] name = "pulseaudio" version = "0.3.1" @@ -6858,16 +6867,6 @@ dependencies = [ "web-transport-trait", ] -[[package]] -name = "quest" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "pulldown-cmark", - "tempfile", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -7838,9 +7837,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -7859,9 +7858,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling", "proc-macro2", @@ -9258,12 +9257,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index 1aaadd72cc..0d602ce5fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,6 @@ members = [ "rs/moq-v4l", "rs/moq-video", "rs/moq-wasm", - "rs/quest", "rs/uniffi-bindgen", ] default-members = [ @@ -80,7 +79,6 @@ default-members = [ "rs/moq-v4l", "rs/moq-video", # "rs/moq-wasm", # wasm32-unknown-unknown target only - "rs/quest", # Maturin runs the workspace's `uniffi-bindgen` binary from the root to # generate the Python wheel, and that only searches default members. A # `cargo build -p moq-ffi` still never compiles it. @@ -122,14 +120,14 @@ dispatch2 = "0.3.1" flate2 = { version = "1.1", default-features = false } futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.6", path = "rs/hang" } +hang = { version = "0.21.8", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" humantime = "2.3" io-uring = "0.7.14" jsonwebtoken = "11" -kio = { version = "0.6.0", path = "rs/kio" } +kio = { version = "0.6.1", path = "rs/kio" } lazy_static = "1.5" libc = "0.2" libloading = "0.9" @@ -140,16 +138,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.1.5", path = "rs/moq-audio", default-features = false } -moq-auth = { version = "0.1.3", path = "rs/moq-auth" } -moq-binary = { version = "0.1.5", path = "rs/moq-binary" } +moq-audio = { version = "0.1.7", path = "rs/moq-audio", default-features = false } +moq-auth = { version = "0.1.5", path = "rs/moq-auth" } +moq-binary = { version = "0.1.7", path = "rs/moq-binary" } moq-flate = { version = "0.2.0", path = "rs/moq-flate" } -moq-hls = { version = "0.5.6", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.5.3", path = "rs/moq-json" } -moq-loc = { version = "0.2.14", path = "rs/moq-loc" } -moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.6", path = "rs/moq-mux" } -moq-net = { version = "0.3.5", path = "rs/moq-net" } +moq-hls = { version = "0.5.8", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.5.5", path = "rs/moq-json" } +moq-loc = { version = "0.2.16", path = "rs/moq-loc" } +moq-msf = { version = "0.5.1", path = "rs/moq-msf" } +moq-mux = { version = "0.10.8", path = "rs/moq-mux" } +moq-net = { version = "0.3.7", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. moq-noq-proto = { version = "2.0", default-features = false } @@ -158,24 +156,24 @@ moq-noq-udp = "2.0" # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. moq-nvenc = { version = "0.1.2", path = "rs/moq-nvenc" } -moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.6", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.6", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.6", path = "rs/moq-rtmp" } +moq-pattern = { version = "0.1.1", path = "rs/moq-pattern" } +moq-relay = { version = "0.15.8", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.8", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.8", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.6", path = "rs/moq-srt" } -moq-stats = { version = "0.2.6", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.17", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.8", path = "rs/moq-srt" } +moq-stats = { version = "0.2.8", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.19", path = "rs/moq-tokio", default-features = false } # Default features off on moq-transcode and moq-video so each workspace consumer # chooses native codecs, OpenH264, and rendering explicitly. Both crates still # provide working native plus software defaults when depended on directly. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.1.5", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.7", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. -moq-uring = { version = "0.0.7", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.9", path = "rs/moq-uring", default-features = false } # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. @@ -188,7 +186,7 @@ moq-vaapi = "0.1.0" # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and moq-c opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.1.5", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.7", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } @@ -203,7 +201,6 @@ objc2-screen-capture-kit = "0.3.2" object_store = { version = "0.14", default-features = false } percent-encoding = "2" pollster = "1.0" -pulldown-cmark = { version = "0.13", default-features = false } qmux = { version = "0.6", default-features = false } rand = "0.10.1" # default-features off so each consumer picks its own TLS backend and body diff --git a/bench/relay.sh b/bench/relay.sh index 82e888bb98..0a7a3369e0 100755 --- a/bench/relay.sh +++ b/bench/relay.sh @@ -58,7 +58,7 @@ relay_config() { "listen = \"127.0.0.1:$port\"" \ '' \ '[auth]' \ - 'public = ""' \ + 'public = "**"' \ "${runtime_config[@]}" >"$path" } diff --git a/bun.lock b/bun.lock index 4005df50c8..661ebc2605 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "moq", "devDependencies": { - "@babel/parser": "^8.0.5", - "@biomejs/biome": "^2.5.13", + "@babel/parser": "^8.0.6", + "@biomejs/biome": "^2.5.14", "concurrently": "^10.0.5", "markdown-extensions": "^2.0.0", "publint": "^0.3.24", @@ -64,11 +64,11 @@ "version": "0.1.0", "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2", "vitepress": "^1.6.4", "vitepress-plugin-llms": "^1.14.0", - "wrangler": "^4.131.2", + "wrangler": "^4.135.0", "yaml": "^2.9.1", }, }, @@ -88,7 +88,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "rimraf": "^6.1.3", "typescript": "7.0.2", }, @@ -119,7 +119,7 @@ "@moq/net": "workspace:*", }, "devDependencies": { - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2", }, }, @@ -146,8 +146,8 @@ "@moq/loc": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "@svta/cml-iso-bmff": "^1.0.5", - "@svta/cml-utils": "1.6.0", + "@svta/cml-iso-bmff": "^1.0.6", + "@svta/cml-utils": "1.6.1", "@zod/mini": "^4.6.5", "zod": "^4.6.5", }, @@ -236,7 +236,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", "typescript": "7.0.2", @@ -264,7 +264,7 @@ "@moq/json": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "mediabunny": "^1.56.2", + "mediabunny": "^1.58.1", }, "devDependencies": { "@types/audioworklet": "^0.0.100", @@ -368,7 +368,7 @@ "zod": "^4.6.5", }, "devDependencies": { - "tsx": "^4.23.13", + "tsx": "^4.23.15", }, }, "test/wasm": { @@ -450,7 +450,7 @@ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@8.0.5", "", { "dependencies": { "@babel/types": "^8.0.5" }, "bin": "./bin/babel-parser.js" }, "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw=="], + "@babel/parser": ["@babel/parser@8.0.6", "", { "dependencies": { "@babel/types": "^8.0.6" }, "bin": "./bin/babel-parser.js" }, "sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], @@ -460,37 +460,37 @@ "@babel/types": ["@babel/types@8.0.6", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.6", "@babel/helper-validator-identifier": "^8.0.6" } }, "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A=="], - "@biomejs/biome": ["@biomejs/biome@2.5.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.13", "@biomejs/cli-darwin-x64": "2.5.13", "@biomejs/cli-linux-arm64": "2.5.13", "@biomejs/cli-linux-arm64-musl": "2.5.13", "@biomejs/cli-linux-x64": "2.5.13", "@biomejs/cli-linux-x64-musl": "2.5.13", "@biomejs/cli-win32-arm64": "2.5.13", "@biomejs/cli-win32-x64": "2.5.13" }, "bin": { "biome": "bin/biome" } }, "sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.14", "@biomejs/cli-darwin-x64": "2.5.14", "@biomejs/cli-linux-arm64": "2.5.14", "@biomejs/cli-linux-arm64-musl": "2.5.14", "@biomejs/cli-linux-x64": "2.5.14", "@biomejs/cli-linux-x64-musl": "2.5.14", "@biomejs/cli-win32-arm64": "2.5.14", "@biomejs/cli-win32-x64": "2.5.14" }, "bin": { "biome": "bin/biome" } }, "sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.13", "", { "os": "win32", "cpu": "x64" }, "sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -832,9 +832,9 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], - "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.5", "", { "peerDependencies": { "@svta/cml-utils": "1.6.0" } }, "sha512-9JM/i5q81/ckFb0V5z2gESX4ICyoBOCwYhLmltPgQXNo55DenORjfY8czgSdlGuMOKxyX1yvwe/OpY4twmqT2g=="], + "@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.6", "", { "peerDependencies": { "@svta/cml-utils": "1.6.1" } }, "sha512-R5QvYqaQzmEexWRqN5bPXmPSwIADKg9VDFTK1cgFg946rAoFuTu7+0g3pCg1ietQvoCFqNHA5fIoCVh6daddgg=="], - "@svta/cml-utils": ["@svta/cml-utils@1.6.0", "", {}, "sha512-h8jd5Y0nrr8wHWkI7XB03Denxy6QR7dgROxchZ2S0VQ4PFINrIu6J0k9N1IGHmtJnJjG3ttszsJd4LS5V/DqcA=="], + "@svta/cml-utils": ["@svta/cml-utils@1.6.1", "", {}, "sha512-jPKb+it53Pi8rbO8fNX8qpbdE+JfDAeOXF7lcG+uVU5BCcR0wbMTT2VtFdMeVsUN57TFPAurRavE3aEUSzC+zw=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -906,7 +906,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], @@ -1402,7 +1402,7 @@ "media-captions": ["media-captions@0.0.18", "", {}, "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ=="], - "mediabunny": ["mediabunny@1.56.2", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-KrrL2Hr47q+IXS19BVWJyYMw1Wb8gz8FkeKP/+ui7q8tPt0lfaio2d9CaGkEWFghaD02wNae/HgYBZTcg5RyZg=="], + "mediabunny": ["mediabunny@1.58.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-edscMXOwBgUlMT+45wpAHdiXTGukCQb1nOJPE2D+fM4esNhAvGyB2yGGgnEer2VSwhEg+7gMliZDDQQqLaR7nA=="], "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], @@ -1458,7 +1458,7 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -1796,7 +1796,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -1880,9 +1880,9 @@ "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 91259c3205..e6e0a496dc 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -4,7 +4,7 @@ # `just obs compile` type-checks the sources on any platform with nothing to # download. The recipes below build a loadable plugin, which is a bigger ask: # -# Linux: `nix develop` provides obs-studio/qt6/ffmpeg/cmake, then `just obs build`. +# Linux: `nix develop .#obs` provides obs-studio/qt6/ffmpeg/cmake, then `just obs build`. # macOS: native, NOT nix. Needs full Xcode; run outside `nix develop` (its # toolchain breaks the Xcode build). `just obs setup` downloads # libobs/Qt6/ffmpeg via buildspec.json (obs-deps). @@ -126,10 +126,10 @@ compile: done exit $status -# Compile and link the plugin the way CI does, against the dev shell's own +# Compile and link the plugin the way CI does, against the `.#obs` shell's own # libobs/Qt6/ffmpeg, then run the unit tests. Linux-only in practice: it's the # one platform where nixpkgs has obs-studio, so nothing has to be downloaded. -# Manual elsewhere, like `just rs macos`. +# Manual elsewhere. # # The tests run here without ThreadSanitizer because this is the only recipe CI # invokes (.github/workflows/obs.yml). `just obs test` is manual, so on its own @@ -223,9 +223,9 @@ _includes: # Unit-test the plugin sources against stubbed libobs/moq-c/ffmpeg, under # ThreadSanitizer. # -# Manual, like `just rs macos` and `just rs windows`, because ThreadSanitizer -# needs its own build. `just obs ci` runs the same tests without it, so a -# regression these assertions catch still turns PR CI red; the sanitizer is what +# Manual because ThreadSanitizer needs its own build. `just obs ci` runs the +# same tests without it, so a regression these assertions catch still turns PR +# CI red; the sanitizer is what # adds the races on top. Run this whenever you touch src/, especially the session # callback plumbing, whose orderings the build can't check. test: diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 7a5e071951..49de54d3ff 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -10,9 +10,7 @@ #include #include -extern "C" { #include "moq.h" -} namespace { diff --git a/cpp/obs/src/moq-settings.cpp b/cpp/obs/src/moq-settings.cpp index 8b3fc4b5a2..a1dcfec192 100644 --- a/cpp/obs/src/moq-settings.cpp +++ b/cpp/obs/src/moq-settings.cpp @@ -7,9 +7,7 @@ #include #include -extern "C" { #include "moq.h" -} namespace MoQSettings { diff --git a/cpp/obs/src/moq-settings.h b/cpp/obs/src/moq-settings.h index 85915adc60..644dfc1f91 100644 --- a/cpp/obs/src/moq-settings.h +++ b/cpp/obs/src/moq-settings.h @@ -5,9 +5,7 @@ #include #include -extern "C" { #include "moq.h" -} // Advanced MoQ connection settings. // diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index 43b38fa1db..96dfae45fd 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -22,8 +22,8 @@ extern "C" { #include #include #include -#include "moq.h" } +#include "moq.h" #include "moq-source.h" #include "moq-url.h" diff --git a/cpp/obs/src/obs-moq.cpp b/cpp/obs/src/obs-moq.cpp index 052b977705..1287a8abff 100644 --- a/cpp/obs/src/obs-moq.cpp +++ b/cpp/obs/src/obs-moq.cpp @@ -27,9 +27,7 @@ with this program. If not, see #include "moq-dock.h" #endif -extern "C" { #include "moq.h" -} #ifdef _WIN64 #include diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index c20c9d30c8..2a56d7e07a 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -22,9 +22,7 @@ #include #include -extern "C" { #include "moq.h" -} #include "moq-settings.h" diff --git a/cpp/obs/test/moq-source-test.cpp b/cpp/obs/test/moq-source-test.cpp index ec9e7e4799..f9d0bb5820 100644 --- a/cpp/obs/test/moq-source-test.cpp +++ b/cpp/obs/test/moq-source-test.cpp @@ -41,8 +41,8 @@ extern "C" { #include #include #include -#include "moq.h" } +#include "moq.h" #include "moq-source.h" diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 41e2aab5bf..1142719a15 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -12970,7 +12970,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce() != - 63513) { + 49647) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqcontainerproducer_cut() != 17534) { @@ -13027,7 +13027,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediaproducer_discontinuity() != - 37570) { + 35894) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediaproducer_finish() != 38480) { diff --git a/dart/moq_ffi/pubspec.lock b/dart/moq_ffi/pubspec.lock index f947f7e68c..39fa5f0025 100644 --- a/dart/moq_ffi/pubspec.lock +++ b/dart/moq_ffi/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: "direct main" description: name: code_assets - sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" collection: dependency: transitive description: diff --git a/demo/pub/bbb.py b/demo/pub/bbb.py new file mode 100644 index 0000000000..4751e119f5 --- /dev/null +++ b/demo/pub/bbb.py @@ -0,0 +1,92 @@ +"""Encode and verify the demo's pre-encoded SD rendition.""" + +import argparse +import json +import subprocess +from fractions import Fraction +from pathlib import Path + + +def probe(path): + return json.loads(subprocess.check_output([ + "ffprobe", "-v", "error", "-show_streams", "-show_packets", + "-show_entries", "stream=index,codec_type,codec_name,width,height,time_base,duration_ts:packet=stream_index,pts,duration,flags", + "-of", "json", str(path), + ])) + + +def video(media): + stream = next(s for s in media["streams"] if s["codec_type"] == "video") + # Fragmented BBB contains duplicate packets marked discard, not visible frames. + packets = [p for p in media["packets"] if p["stream_index"] == stream["index"] and "D" not in p["flags"]] + return stream, packets + + +def check(source, target): + source_media = probe(source) + source_stream, source_packets = video(source_media) + target_media = probe(target) + target_stream, target_packets = video(target_media) + assert len(target_media["streams"]) == 1, "SD must be video only" + assert (target_stream["codec_name"], target_stream["width"], target_stream["height"]) == ("h264", 640, 360) + for stream, packets in [(source_stream, source_packets), (target_stream, target_packets)]: + assert all(a["pts"] < b["pts"] for a, b in zip(packets, packets[1:])), "Non-increasing frame timestamps" + source_frames = [(p["pts"] * Fraction(source_stream["time_base"]), "K" in p["flags"]) for p in source_packets] + target_frames = [(p["pts"] * Fraction(target_stream["time_base"]), "K" in p["flags"]) for p in target_packets] + assert source_frames == target_frames, "SD frame timestamps/keyframes differ from source" + + # Map audio too: it determines the source's loop period. A video-only probe + # would miss the 39 ms/loop drift caused by the longer audio track. + output = subprocess.check_output([ + "ffmpeg", "-v", "error", "-copyts", "-stream_loop", "2", "-i", str(source), + "-stream_loop", "2", "-i", str(target), "-map", "0", "-map", "1:v", + "-c", "copy", "-f", "framecrc", "-", + ], text=True) + timestamps = {} + time_bases = {} + for line in output.splitlines(): + if line.startswith("#tb "): + index, base = line[4:].split(": ") + time_bases[int(index)] = Fraction(base) + elif not line.startswith("#"): + fields = line.split(",") + index, _, pts = map(int, fields[:3]) + timestamps.setdefault(index, set()).add(pts * time_bases[index]) + target_index = len(source_media["streams"]) + assert timestamps[source_stream["index"]] == timestamps[target_index], "Renditions drift across loops" + print(f"Verified {len(source_frames)} frames, {sum(key for _, key in source_frames)} keyframes, and three aligned loops") + + +def encode(source, target): + media = probe(source) + stream, packets = video(media) + base = Fraction(stream["time_base"]) + # FFmpeg loops all selected source streams at the longest stream duration. + # Hold the SD's final frame for the same period, rounded to the video clock. + duration = max(s["duration_ts"] * Fraction(s["time_base"]) for s in media["streams"]) + loop_ticks = int(duration / base + Fraction(1, 2)) + final_duration = loop_ticks - (packets[-1]["pts"] - packets[0]["pts"]) + assert final_duration > 0, "Source duration ends before its final video frame" + subprocess.run([ + "ffmpeg", "-hide_banner", "-loglevel", "warning", "-nostdin", "-n", "-copyts", "-i", str(source), + "-map", "0:v:0", "-an", "-vf", "scale=-2:360", "-fps_mode", "passthrough", + "-enc_time_base", "demux", "-c:v", "libx264", "-preset", "slow", + "-b:v", "600k", "-maxrate", "600k", "-bufsize", "1200k", "-bf", "0", + "-g", "2147483647", "-sc_threshold", "0", "-force_key_frames", "source", + "-bsf:v", f"setts=duration=if(eq(N\\,{len(packets) - 1})\\,{final_duration}\\,DURATION)", + "-f", "mp4", "-movflags", "cmaf+separate_moof+delay_moov+skip_trailer", + "-frag_duration", "1000", str(target), + ], check=True) + check(source, target) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["encode", "check"]) + args = parser.parse_args() + source = Path("media/bbb.mp4") + target = Path("media/bbb-sd.mp4") + if args.action == "encode": + encode(source, target) + else: + check(source, target) diff --git a/demo/pub/bun.lock b/demo/pub/bun.lock index 8a83558a98..f88a9755d4 100644 --- a/demo/pub/bun.lock +++ b/demo/pub/bun.lock @@ -15,17 +15,17 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260915.1", "", {}, "sha512-3Lw2wkyVcEDdD0W6rNBmannaAkUsWGsO5TjbyHflWIDlhxtFRDFnxqHoDlg4YiVxinsIcBl1j9jW1kdruUhZpg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260920.1", "", {}, "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -167,7 +167,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -185,9 +185,9 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], diff --git a/demo/pub/justfile b/demo/pub/justfile index 4ddd0e18bb..fb780f5e73 100644 --- a/demo/pub/justfile +++ b/demo/pub/justfile @@ -38,7 +38,24 @@ list: # Publish Big Buck Bunny to a relay server. bbb url='http://localhost:4443' *args: just download bbb - just ts bbb "{{ url }}" {{ args }} + just download bbb-sd + cargo build --bin moq + ffmpeg -hide_banner -loglevel warning -copyts \ + -stream_loop -1 -re -i media/bbb.mp4 \ + -stream_loop -1 -re -i media/bbb-sd.mp4 \ + -map 0 -map 1:v -c copy -f mpegts -pes_payload_size 0 - \ + | cargo run --bin moq -- {{ args }} --connect "{{ url }}" --broadcast bbb.hang import ts + +# Encode the video-only SD rendition, preserving source timestamps and loop duration. +encode-bbb-sd: + just download bbb + python3 bbb.py encode + +# Verify the hosted rendition's frames, keyframes, and three complete loops. +check-bbb: + just download bbb + just download bbb-sd + python3 bbb.py check # Publish Tears of Steel to a relay server. tos url='http://localhost:4443' *args: diff --git a/demo/relay/leaf0.toml b/demo/relay/leaf0.toml index 4fc49d1b4b..40872815d0 100644 --- a/demo/relay/leaf0.toml +++ b/demo/relay/leaf0.toml @@ -13,10 +13,11 @@ bind = "[::]:4444" tls.cert = ["leaf0.crt"] tls.key = ["leaf0.key"] -# Trust client certificates signed by this CA for mTLS peer auth. The -# certificate is a fact for the auth source below; an auth server grants -# peers through `--mtls-publish` / `--mtls-subscribe`. -tls.root = ["ca.pem"] +# To authenticate peers by certificate, trust client certificates signed by +# this CA and switch `[auth]` below to the auth server, which grants them +# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore +# certificates, so the relay refuses a client CA alongside them. +# tls.root = ["ca.pem"] [connect] # Present this certificate and key on outbound cluster connections; the @@ -44,7 +45,7 @@ public = "**" # To exercise JWT + public-pattern auth instead, drop `public` above, run # `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`, -# and point the relay at it: +# point the relay at it, and uncomment `listen.tls.root` above: # url = "http://127.0.0.1:4440/" [stats] diff --git a/demo/relay/leaf1.toml b/demo/relay/leaf1.toml index 463253940f..c112e619ac 100644 --- a/demo/relay/leaf1.toml +++ b/demo/relay/leaf1.toml @@ -13,10 +13,11 @@ bind = "[::]:4445" tls.cert = ["leaf1.crt"] tls.key = ["leaf1.key"] -# Trust client certificates signed by this CA for mTLS peer auth. The -# certificate is a fact for the auth source below; an auth server grants -# peers through `--mtls-publish` / `--mtls-subscribe`. -tls.root = ["ca.pem"] +# To authenticate peers by certificate, trust client certificates signed by +# this CA and switch `[auth]` below to the auth server, which grants them +# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore +# certificates, so the relay refuses a client CA alongside them. +# tls.root = ["ca.pem"] [connect] # Present this certificate and key on outbound cluster connections; the @@ -44,7 +45,7 @@ public = "**" # To exercise JWT + public-pattern auth instead, drop `public` above, run # `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`, -# and point the relay at it: +# point the relay at it, and uncomment `listen.tls.root` above: # url = "http://127.0.0.1:4440/" [stats] diff --git a/demo/relay/prod.toml b/demo/relay/prod.toml index 069bdc0a52..287af9c74d 100644 --- a/demo/relay/prod.toml +++ b/demo/relay/prod.toml @@ -37,6 +37,3 @@ key = "key.pem" # For multiple keys with rotation, give it `--key-dir /path/to/keys/` instead: # keys are named {kid}.jwk and resolved by the kid in the JWT header. url = "http://127.0.0.1:4440/" - -# Or use a URL for remote key management: -# key_dir = "https://api.example.com/keys" diff --git a/demo/relay/root.toml b/demo/relay/root.toml index b3fbda5623..0597547604 100644 --- a/demo/relay/root.toml +++ b/demo/relay/root.toml @@ -14,11 +14,12 @@ bind = "[::]:4443" tls.cert = ["root.crt"] tls.key = ["root.key"] -# Trust client certificates signed by this CA for mTLS peer auth. The -# certificate is a fact for the auth source: `auth.public = "**"` below admits -# the leaves, and an auth server would grant them through `--mtls-publish` / -# `--mtls-subscribe`. `just ca` generates the CA. -tls.root = ["ca.pem"] +# To authenticate peers by certificate, trust client certificates signed by +# this CA and switch `[auth]` below to the auth server, which grants them +# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore +# certificates, so the relay refuses a client CA alongside them. +# `just ca` generates the CA. +# tls.root = ["ca.pem"] [web.http] # Listen for HTTP and WebSocket (TCP) connections on the given address. @@ -31,5 +32,5 @@ public = "**" # To exercise JWT + public-pattern auth instead, drop `public` above, run # `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`, -# and point the relay at it: +# point the relay at it, and uncomment `listen.tls.root` above: # url = "http://127.0.0.1:4440/" diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index f3d77c370f..c9d2d97b64 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -13,8 +13,8 @@ * sessions.json sessions by auth root * * Each frame is `{ "": Snapshot }`. Counters are cumulative; - * "active" = started - ended. The relay only includes currently-live entries, so - * the latest frame is a snapshot of now. We sample the aggregate on an interval + * "active" = started - ended. The relay includes every entry it still holds + * counters for, idle ones too, so the latest frame is a snapshot of now. We sample the aggregate on an interval * to derive per-second throughput rates for the charts. A relay built after the * started/ended rename still writes the legacy names beside the new ones; this * dashboard prefers the canonical spelling and falls back so it also reads an diff --git a/doc/bin/cli.md b/doc/bin/cli.md index c92cdd76bb..287ae9445f 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -41,7 +41,10 @@ moq fetch [options] The **MoQ side** goes first and attaches the process to the network: `--connect ` dials a relay (the path is the auth path, `?jwt=` carries a token), and `--broadcast ` names the broadcast. A process can -instead host sessions with `--listen`, or both at once. `moq import --help` lists the sources and `moq import rtmp --help` a specific one. +instead host sessions with `--listen`, or both at once. A listener admits +clients by `--auth-url` or `--auth-public`, as the relay does (see +[Authentication](/bin/relay/auth)); public rules ignore certificates, so +`--auth-public` refuses to start with `--listen-tls-root`. `moq import --help` lists the sources and `moq import rtmp --help` a specific one. ```bash # Publish a file (remux to MPEG-TS without re-encoding) @@ -111,10 +114,13 @@ behind it. Once the speaker owns the clock, video follows the speaker instead. Video receives encoded frames independently of decoding, so a tune-in burst can update that clock even while the window is waiting for its first picture. Encoded video is retained within the delay budget with byte accounting; a skip -resumes at a keyframe. Decoding starts at most 100 ms before presentation, and -the window holds at most three decoded pictures. A stalled window loses its -oldest picture instead of blocking reception. The configured delay therefore -does not turn into seconds of raw video surfaces. +resumes at a keyframe. Decoding starts 100 ms before the earliest picture still +owed is due, so B-frame reordering and pictures the decoder holds back are +covered however deep they go. The window holds at most three decoded pictures; +a larger decoder batch waits for room rather than pushing out pictures not yet +shown. A stalled window loses its oldest picture once a newer one is due, +instead of blocking reception. The configured delay therefore does not turn +into seconds of raw video surfaces. Each role follows the catalog for as long as it lasts. Each decoder starts at the newest cached group, including when a rendition is reopened, so playback @@ -161,8 +167,8 @@ watches them. On NVIDIA the whole pipeline stays on the GPU; `--frames cpu` forces decoded frames into CPU memory instead of the default `native`. Requires the `transcode` feature. -The source is the tallest rendition this host can decode with `--decoder`, so a -software-only host transcodes from an H.264 rendition rather than a taller H.265 +The source is the largest rendition this host can decode with `--decoder`, so a +software-only host transcodes from an H.264 rendition rather than a larger H.265 or AV1 one. When no rendition decodes, the command exits naming the decoder's refusal. diff --git a/doc/bin/obs.md b/doc/bin/obs.md index bab4d65b72..7f1de4429d 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -67,7 +67,7 @@ Extract into your OBS plugins directory. The archives are unsigned, so Gatekeeper and SmartScreen warn on first load. Linux builds from source: ```bash -nix develop +nix develop .#obs just obs build ``` diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index f3d7e7ce7f..5b624bc874 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -10,7 +10,7 @@ A relay admits a session in exactly one of two ways: | Flag | What admits | | --- | --- | | `--auth-url` | An auth server, asked once per session event with everything the relay knows. `moq auth serve` is the reference server; a Worker or a service of your own answers the same contract. | -| `--auth-public` | A static grant for anonymous sessions: patterns under the dialed path, no server. | +| `--auth-public` | A static grant for anonymous sessions: patterns rooted at `/`, like a token with an empty root. No server. | Setting both, or neither, fails at startup; an application that embeds the relay may leave both unset and decide [in process](#in-process) instead. @@ -30,17 +30,26 @@ grant back. The schemas are `moq_auth::Request` and `moq_auth::Grant` `http` for a one-shot `/fetch` or `/announced` request), `remote` and `local` socket addresses, `server_name` (the SNI or the host the client addressed), `alpn` (the negotiated moq protocol), `path` exactly as dialed, `query` raw, -`role` the client declared at SETUP (`publisher`, `subscriber`, absent for -both), and `tls` with the verified client certificate when one was presented: +`token` with the credential a moq-transport client put in its SETUP's +`AUTHORIZATION TOKEN` option (`kind`, the draft's Token Type, and `value`, the +bytes as unpadded base64url), `role` the client declared at SETUP +(`publisher`, `subscriber`, absent for both), and `tls` with the verified client certificate when one was presented: `name` (first SAN DNS name, else CN, else the fingerprint; a server cannot tell which), `fingerprint` (SHA-256 of the leaf), `expires`, `issuer`. Nothing is parsed on the server's behalf: the `jwt` query parameter is a convention of `moq auth serve`, not of -the relay. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and +the relay, and the relay forwards a SETUP token without reading it. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and received. **Grant.** `publish` and `subscribe` as pattern unions (`foo/**` is a subtree, `**` is everything, an empty list is nothing), `root` (optional; replaces the -dialed path, which is how a slug aliases to a canonical id), `expires` +dialed path, which is how a slug aliases to a canonical id), `mounts` +(optional object; each key, a path relative to the root, reads from the +absolute path it maps to: `{".svc": ".svc/pid"}` resolves `.svc/foo` at +`.svc/pid/foo` and presents its announcements under `.svc`, the patterns still +authorize `.svc/foo`, and nothing may be published beneath a key; a key +that holds a wildcard or overlaps another key or any value, its own included, +refuses the grant), +`expires` (optional unix seconds; the session closes then), `revalidate` (optional seconds until the relay asks again), `tier` (optional label handed to [stats](/bin/relay/config#stats)), and `peer` (optional; `true` marks another @@ -49,13 +58,13 @@ ingest here). A 2xx with a grant admits. A 401 or 403 refuses. Anything else at connect, a timeout, a 5xx, or an unparseable body, refuses and logs an error; nothing is admitted because the server was down. A grant that names nothing refuses, and one with `revalidate` but no `expires` is refused -as invalid. A grant already less than five seconds past `expires` is accepted -for the remainder of that clock-skew window; future expiries are unchanged. +as invalid, as is one whose `expires` is already at or before now: expiry is +exact, with no grace for clock skew, so keep the auth server's clock in sync. The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the -same request. A grant applies: a changed `root` or one that no longer covers +same request. A grant applies: a changed `root` or `mounts`, or one that no longer covers what the session holds closes it with `Unauthorized` (the live session is not resized in place), and so does a flipped `peer`; a changed `tier` keeps the session and moves its stats: its presence counts under the new tier from then @@ -99,7 +108,7 @@ moq auth sessions --internal-url http://127.0.0.1:9101 --path 'demo/**' ``` `GET /sessions` is the dry run: the same matches, each request plus start -time, with `query` omitted so a jwt on the plane cannot be replayed. A +time, with `query` and `token` omitted so a credential on the plane cannot be replayed. A matching POST returns 202 and the ids; no match is 200 with an empty list. An unknown field, including `query`, is 400. One node, no cluster fan-out: the server already knows each session's `node` from `connect` and calls that @@ -143,7 +152,9 @@ moq auth verify --key public.jwk --in alice.jwt moq auth serve --key public.jwk # or --key-dir /etc/moq/keys/ or --key-set /etc/moq/keys.jwks ``` -The client dials `https://relay.example.com/rooms/123?jwt=`. HMAC +The client dials `https://relay.example.com/rooms/123?jwt=`. A +moq-transport client can instead put the JWT in its SETUP's `AUTHORIZATION +TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC (HS256/384/512), RSA (RS/PS), ECDSA (ES256/384), and EdDSA keys all work. A key can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. @@ -155,7 +166,12 @@ after which it can never sign a broader token. | `root` | Base path. Optional. | | `publish` | Patterns the bearer may publish under `root`. `**` means everything; omitted means no publishing. | | `subscribe` | Patterns the bearer may subscribe to under `root`. Same rules. | -| `exp`, `iat` | Expiry and issue time. `exp` is enforced for the whole session, not just at connect. | +| `exp`, `iat`, `nbf` | Expiry, issue time, and not-before. `exp` is enforced for the whole session, not just at connect, and a token is refused from its `exp` on and before its `nbf`. | +| `iss`, `sub`, `jti` | Read and ignored. | + +Any other claim refuses the token with its name, `aud` included: an unknown +claim may narrow the grant, and a misspelled `root` would otherwise widen it to +everything. Put application data somewhere other than the token. Tokens and key scopes from the older `moq-token` format still work: each `put` and `get` prefix `p` reads as the subtree `p/**`, and `""` as `**`. When every @@ -175,6 +191,19 @@ at the dialed path: the path may equal the root, extend it (which narrows the grant), or be a parent of it (the grant still applies at the root). An unrelated path is rejected. The relay forwards the raw path and enforces the grant it gets. +The session's root is always the path it dialed, and what it sees is named +relative to that. For a grant of `demo/**` from the root: + +| Dialed | Session root | Granted | +| --- | --- | --- | +| `/demo/bar` | `demo/bar` | `**` | +| `/demo` | `demo` | `**` | +| `/` | \`\` | `demo/**` | +| `/other` | refused | | + +Anonymous and mTLS rules, on the relay and in `moq auth serve`, are authorized +the same way, as a token with an empty root. + | root | publish | subscribe | Publish | Subscribe | | --- | --- | --- | --- | --- | | `demo` | `my-stream/**` | `**` | `demo/my-stream/**` | `demo/**` | @@ -194,7 +223,14 @@ public_subscribe = ["anon/**", "demo/**"] public_publish = ["anon/**"] ``` -A static grant with no expiry and no re-check. `public = "**"` opens everything +A static grant with no expiry and no re-check. Nothing here verifies a token, +so a session presenting one (a `jwt` query or any SETUP token) is refused +rather than admitted on the public grant. The patterns are rooted at `/`, +like a token with an empty root (see [Path matching](#path-matching)): `anon/**` +admits a session dialed at `/`, `/anon`, or `/anon/room`, scoped to `anon/`, +and refuses one dialed anywhere else. A pattern with no wildcard, such as +`anon`, refuses to start: 0.14 read it as a prefix and a pattern reads it as one +broadcast, so write `anon/**` for the subtree. `public = "**"` opens everything and is for development only. With `--auth-url` the anonymous rules live on the server instead (`moq auth serve --public-*`). @@ -204,8 +240,9 @@ server instead (`moq auth serve --public-*`). bad chain still fails there. What the certificate admits is the server's decision: the relay reports its facts in the request's `tls` and enforces the grant it gets back. `moq auth serve` grants a certificate only what -`--mtls-publish` and `--mtls-subscribe` name, empty by default. A relay on -`--auth-public` grants a certificate what it grants everyone. +`--mtls-publish` and `--mtls-subscribe` name, empty by default. Public rules +ignore certificates, so a relay or `moq --listen` on `--auth-public` refuses +to start with `listen.tls.root` or `web.https.root`. Cluster peers are admitted the same way, so a mesh runs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server @@ -233,30 +270,44 @@ moq auth serve --listen 127.0.0.1:4440 \ --key-dir /etc/moq/keys \ --public-subscribe 'anon/**' --public-publish 'anon/**' \ --mtls-publish '**' --mtls-subscribe '**' \ - --tier edge --revalidate 1m --limit-remote 64 + --tier edge --expires 1d --revalidate 1m --limit-remote 64 ``` -Policy runs in this order and stops at the first that applies: - -1. A `jwt` in the query is verified against `--key FILE`, `--key-dir DIR`, or `--key-set FILE` - (by `kid`, read per request so rotation needs no restart). Its claims - are authorized at the dialed path (`Claims::authorize`): the path may - equal the root, extend it (which narrows the grant), or be a parent of - it (the grant stays anchored at the root). Residuals become the grant. - An unrelated path, or one the token grants nothing at, is refused. A - malformed, expired, or unknown-key token is refused; it never falls - through to the anonymous rules. -2. A verified client certificate gets `--mtls-publish` and - `--mtls-subscribe`, and nothing when they are empty. Cluster peers are - admitted this way; a mesh needs `'**'` for both. -3. Anything else gets `--public-publish` and `--public-subscribe`, and is - refused when they are empty. - -Every grant carries `--tier`, a `revalidate` cadence (`--revalidate`, default -one minute), and an `expires`: the token's `exp`, the certificate's notAfter, -or `--expires` (default one day) when neither has one. - -`--limit-token N` and `--limit-remote N` cap live sessions per token and +What a session presents decides what is checked. Every credential is either +evaluated or refused: nothing is ignored, and no two are combined. + +| Presented | `--auth-public` | `--auth-url` to `moq auth serve` | +| --- | --- | --- | +| nothing | the public rules | `--public-*`, or refused | +| a JWT | refused | verified, or refused | +| a certificate | never requested: a client CA refuses to start | `--mtls-*`, or refused | +| a JWT and a certificate | never requested | refused | + +A JWT, from the `jwt` query or a SETUP `token` of `kind` 0, is verified +against `--key FILE`, `--key-dir DIR`, or `--key-set FILE` (by `kid`, read per +request so rotation needs no restart) and authorized at the dialed path, as in +[Path matching](#path-matching). A malformed, expired, or unknown-key token is +refused; it never falls through to the anonymous rules. So is a SETUP token of +any other `kind`. A SETUP token and a `jwt` query with the same value are one +JWT, verified once; different values are refused. +A JWT presented with a certificate is refused, because neither can safely win: +the certificate would override a JWT meant to narrow it, and the JWT would +narrow or refuse a peer by accident. So a peer presents `cluster.token` or a +certificate, not both. + +`--mtls-*` and `--public-*` are rooted at `/`, like a token with an empty root, +and a session they reach nothing at is refused. Cluster peers are admitted by +certificate; a mesh needs `--mtls-publish '**' --mtls-subscribe '**'`. + +Every grant carries `--tier`. As in 0.14, nothing is re-checked or closed by +default: a session lives until its token's `exp` or its certificate's notAfter. +`--expires D` bounds a grant with no bound of its own (an anonymous session, a +token without `exp`, a certificate without notAfter). `--revalidate D` has the +relay re-check each grant on that cadence and needs `--expires`, so an outage +still has a bound. Without `--revalidate`, rotating or deleting a key does not +close live sessions. + +`--limit-token N` and `--limit-remote N` need `--revalidate`, and cap live sessions per token and per remote address (port dropped, IPv4-mapped IPv6 folded), counted from `connect` and `end` by session id. The cap is a nuisance limit, not a security boundary: it gates admission and never revokes. A relay that dies without an @@ -271,10 +322,12 @@ its own. ### Migrating from the relay flags -The relay flags below were deleted; the policy moves to the server and the -relay gets `--auth-url`. +Most of the 0.14 relay flags below were deleted; their policy moves to the +server and the relay gets `--auth-url`. A relay without a server keeps +`--auth-public`, as patterns: `--auth-public 'PREFIX/**'`. With a server, the +anonymous rules move too. -| Removed relay flag | `moq auth serve` | +| 0.14 relay flag | `moq auth serve` | | --- | --- | | `--auth-key FILE` | `--key FILE` | | `--auth-key-dir DIR` | `--key-dir DIR` | diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index ddeb3ae081..f4c3466a5d 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -16,6 +16,12 @@ same way so the cluster converges instead of flapping. Both wire protocols carry it: natively on moq-lite, and via the [cluster extension](/draft/moq-cluster) on moq-transport 17+. +When a moq-lite-04 or later peer withdraws its last advertisement for a broadcast, a relay +drops every other route to it that passed through that peer, since each was +relayed from what the peer just withdrew, rather than falling back to them one +by one. During reconnect, another session from that peer can still advertise +the broadcast; an old session's withdrawal does not invalidate that route. + Failover routes must carry copies of the same broadcast. For each track, the relay requires matching timescale, retention window, publisher priority, and group ordering. A source with different properties is refused before its groups @@ -177,7 +183,8 @@ accepting relay admits a peer through the same lease as any client: its certificate is reported to the auth server, which grants it, so a mesh needs `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server of your own that grants the cluster CA) behind `--auth-url`. A relay on -`--auth-public '**'` admits peers through that grant instead. LAN peers +`--auth-public '**'` admits peers through that grant instead, as long as they +send no `cluster.token`: public rules refuse a token. LAN peers authenticate with the mDNS credential on `/.cluster/`, a secret the relay minted for itself and checks locally, and never receive `cluster.token`. Dials retry forever with capped backoff, so a rejected peer diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 8f5484fcba..d5febea5b8 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -119,7 +119,7 @@ See [HTTP endpoints](/bin/relay/http). # Exactly one of these: url = "http://127.0.0.1:4440/" # An auth server asked once per session event (`moq auth serve`, # or your own). https:// presents connect.tls; unix:// is a socket. -# public = "anon/**" # Or a static anonymous grant, publish and subscribe alike. +# public = "anon/**" # Or a static anonymous grant rooted at /, publish and subscribe alike. # public_subscribe = ["anon/**", "demo/**"] # Or split them; patterns, `foo/**` for a subtree. # public_publish = ["anon/**"] ``` diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index ed48f20ddc..23743393e0 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -12,7 +12,7 @@ operational ones that must stay private. | Endpoint | Returns | | --- | --- | -| `GET /announced/` | Broadcasts announced under the prefix. | +| `GET /announced/` | Broadcasts announced under the prefix, named relative to it. | | `GET /fetch//?group=N` | One group from the cache, the latest by default, or `404` if the track has no such group. Useful for catch-up and debugging. | | `GET /certificate.sha256` | The fingerprint of the first configured TLS certificate, for pinning a self-signed dev certificate. | | `GET /health` | `200 ok`, unauthenticated, for load balancers. | diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index 969ab37397..f0caabd011 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -12,7 +12,7 @@ relay serves video, audio, and data alike. ## Features - **QUIC, WebTransport, and WebSocket** listeners, so browsers and native clients connect to one process. -- **Path-scoped authentication** with JWTs, mTLS for peers, anonymous prefixes, and an optional auth API for dynamic policy. See [Authentication](/bin/relay/auth). +- **Path-scoped authentication** with JWTs, mTLS for peers, and anonymous patterns, decided by an auth server or a static grant. See [Authentication](/bin/relay/auth). - **Clustering** across hosts and regions with hop-list routing, per-link costs, gossip discovery, and dynamic peer lists. See [Clustering](/bin/relay/cluster). - **A group cache** with byte and age budgets, so late joiners and the HLS gateway can fetch recent history. - **HTTP endpoints** to list broadcasts, fetch groups, probe health, and scrape Prometheus metrics. See [HTTP](/bin/relay/http). @@ -38,7 +38,7 @@ tls.generate = ["localhost"] listen = "[::]:4443" # serves the certificate fingerprint for local browsers [auth] -public = "" # anonymous access to everything; development only +public = "**" # anonymous access to everything; development only ``` Every option is also a `--flag` or `MOQ_*` environment variable, and diff --git a/doc/bin/rtc.md b/doc/bin/rtc.md index 5fb0c25201..989a4d9c3a 100644 --- a/doc/bin/rtc.md +++ b/doc/bin/rtc.md @@ -23,7 +23,8 @@ moq --connect https://relay.example.com/anon --broadcast cam.hang export rtc --l ``` Peers reach the broadcast at `http://host:8080/`. Opus, H.264, -H.265, VP8, VP9, and AV1 are negotiated in both directions. `--cors-origin` +H.265, VP8, VP9, and AV1 are negotiated in both directions. A WHEP peer +receives the largest rendition (then highest bitrate) in its negotiated codec. `--cors-origin` opens the endpoint to browsers on other origins, and `--udp-bind` plus `--public-addr` pin one media port for firewalls. The listener is plain HTTP; put a TLS-terminating proxy in front for WHIP clients that require HTTPS. A fresh WHEP peer joins at the current group, so it diff --git a/doc/bin/rtmp.md b/doc/bin/rtmp.md index dee1c0f39d..6a144135d9 100644 --- a/doc/bin/rtmp.md +++ b/doc/bin/rtmp.md @@ -29,6 +29,10 @@ the [`moq-rtmp`](https://docs.rs/moq-rtmp) library, which hands you each publish or play request to accept, map to a path, or reject. The CLI listener is unauthenticated; firewall it. +A player that advertises enhanced-RTMP multitrack receives every rendition. +Any other player receives one video rendition: the largest picture (then +highest bitrate) in a codec it advertised. A push carries the largest one. + Implemented in pure Rust (no librtmp). The CLI speaks plaintext `rtmp://` only; the library adds RTMPS on the same port when the embedder supplies a TLS config. FLAC and MP3 enhanced-audio payloads are dropped because hang has no diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 20f3bfc0ed..39ec8b972f 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -105,8 +105,9 @@ document would silently discard everything but the last payload: The rest is descriptive: `compression` (`deflate`, the same group-scoped `deflate-raw` the catalog uses), `schema` on a JSON track, `mime` on a binary -one, `bitrate` and `jitter` with the same meaning as for media, plus the -optional `broadcast` reference. A +one, `bitrate`, `jitter`, and `delay` with the same meaning as for media, plus +the optional `broadcast` reference. A publisher measures `jitter` and `delay` +only from payloads stamped with their capture time on the broadcast clock. A consumer that doesn't recognize a `mode` or `compression` ignores that track and round-trips it verbatim. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 994e1fb264..8e2696601f 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -34,6 +34,19 @@ Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts still in progress: it negotiates as `moq-lite-07-wip`, and only when both sides explicitly enable it. +## Subscription completion + +On moq-lite 07, `SUBSCRIBE_END` counts the group streams opened for the +subscription. Rust and TypeScript stop waiting for missing streams once that +many headers have arrived; skipped group sequences add no wait. Groups already +being received continue until their own stream ends or resets. + +A stream reset before its header arrived cannot be counted, so the subscriber +still allows a grace period for late streams. The grace uses the subscription's +nonzero effective maximum age, or one second when no maximum age is set. +moq-lite 05 and 06 instead account for group sequences using received headers +and `SUBSCRIBE_DROP`. + ## Discovery A session can ask for announcements matching a path prefix. The peer replies @@ -136,7 +149,9 @@ prefixes it is told about. A subscriber watching under a root sees advertisements named relative to that root. The pattern scope filters which prefixes are visible without changing a -route's prefix. Announce events carry the covered path, captures, and what +route's prefix. When several routes advertise one prefix, each reader sees the +best route its scope can use, so a cheaper route scoped elsewhere never hides +it. Announce events carry the covered path, captures, and what happened to it: Rust `announce::Event::{Start, Update, End}`, each holding an `announce::Announce { prefix, captures: Option>, route }`, and TypeScript `Announce.Event`, whose `kind` is `"start"`, `"update"`, or @@ -158,7 +173,12 @@ 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. 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 +relative to `root`. Nested scopes intersect with their parent. In Rust, +`origin.mount(at, target)` reads the subtree at `at` from `target` instead: a +request for `at/rest` joins the one front at `target/rest`, announcements under +`target` present under `at`, the handle's patterns still authorize `at/rest`, +and nothing is published beneath `at`. Mounts never chain: a mount point that +overlaps another mount's point or any target, its own included, is refused. 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; diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 7305af1566..6c7b3b8b48 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -41,6 +41,15 @@ because they do not issue joining fetches. Other publishers may replay a cached backlog for that filter; selecting the next group instead would leave static tracks waiting for a group that never arrives. +A client may present one credential in its `SETUP` with the `AUTHORIZATION +TOKEN` option. The server reads a value (`USE_VALUE`, or `REGISTER`, which it +treats as a value since it advertises no token cache) and hands its Token Type +and bytes to the application unverified; a relay forwards them to its +[auth server](/bin/relay/auth#the-contract). An alias reference (`DELETE`, +`USE_ALIAS`) closes the session with `PROTOCOL_VIOLATION`, a structure that +does not decode with `KEY_VALUE_FORMATTING_ERROR`, and a second token is +refused. + Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [solicit](/draft/moq-solicit) to make announcements opt-in, diff --git a/doc/concept/stats.md b/doc/concept/stats.md index 7b6cb321ca..6abee77e94 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -62,10 +62,12 @@ held open with `{}` until the tier records. Any other name is refused. ## Frames Every frame is a JSON object mapping a key (broadcast path or auth root) to an -entry. An entry appears while it is **live**, meaning some started counter -still exceeds its ended counterpart so traffic could resume at any moment, and -on any tick its counters changed. Once fully closed it appears one last time -with its final counters and is then dropped. A track with no entries holds `{}`. +entry. A traffic entry appears from its first nonzero counter until the relay +drops its counters, even while idle: the last viewer can leave while the +publisher still holds the path, and a returning viewer resumes the same +counters. So a key missing from a frame had no counters, and one that returns +starts from zero. A sessions entry appears while a session is connected and on +the tick its last one disconnects. A track with no entries holds `{}`. The producer drains its counters every interval (one second by default) and writes only when a track's frame changed, so silence means nothing moved, not diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 3f2aaaa533..23e2b39023 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -31,10 +31,10 @@ With CMake, point `CMAKE_PREFIX_PATH` at the extracted archive, then From source, `add_subdirectory(rs/moq-c)` in CMake gives the same `moq::c` target. A bare `cargo build --release -p moq-c` writes `target/release/libmoq.a`, -and `moq.h` plus `moq-c.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-c.pc` expects the install layout, with `libmoq.a` in `lib/` -beside `pkgconfig/`. +and `moq.h` lands in the build script's `OUT_DIR` under `include/`, a hashed +path that `--message-format=json` reports as `out_dir`. It writes no `moq-c.pc`; +`nix build .#moq-c` produces the same install layout as the release tarball, +pkg-config file included. ## Shape of the API @@ -68,7 +68,7 @@ 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 moq-c. 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. +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. On a video track, resume with a keyframe: a delta frame before it fails. ## Connection stats diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 55dec882de..de4bcfbf83 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -117,7 +117,7 @@ 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. +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. On a video track, resume with a keyframe: a delta frame before it fails. ## Connection stats diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index cc30775649..20a7c9d590 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -75,7 +75,7 @@ broadcast.Close() // keep the producer reachable while publishing, then close 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. +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. On a video track, resume with a keyframe: a delta frame before it fails. The three advertising operations: `client.CreateBroadcast(path)` (or `origin.CreateBroadcast`) returns an unannounced producer, invisible to everyone; @@ -94,6 +94,12 @@ what is live and stop. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless `Hidden: true`. +An `OriginProducer` from `moq.NewOriginProducer` has no `Close`: its origin +ends when the garbage collector reaches the last producer, and every consumer +and `OriginDynamic` made from it then fails with `moq.ErrClosed`. Keep the +producer reachable (a field on a long-lived struct, or `runtime.KeepAlive`) for +as long as the origin should serve. + Every call that can block takes a `context.Context` first. Cancelling it returns `ctx.Err()` promptly and tears the in-flight native work down, so a per-call deadline bounds resource use rather than just your wait. What it tears diff --git a/doc/lib/js/binary.md b/doc/lib/js/binary.md index 71a8729815..d761026b52 100644 --- a/doc/lib/js/binary.md +++ b/doc/lib/js/binary.md @@ -26,4 +26,7 @@ const producer = new Snapshot.Producer({ track, compression: "deflate" }); producer.update(payload); ``` +A payload is stamped when written, unless you pass its capture time: +`producer.update(payload, at)`. + The Rust twin is [`moq-binary`](/lib/rs/moq-binary). diff --git a/doc/lib/js/hang.md b/doc/lib/js/hang.md index e7f14a5b5a..07d9325f5b 100644 --- a/doc/lib/js/hang.md +++ b/doc/lib/js/hang.md @@ -20,7 +20,9 @@ import * as Container from "@moq/hang/container"; ``` `Catalog.watch(broadcast)` iterates validated catalog roots. It throws -`Catalog.TooManyRenditions` for an update above the 64 rendition limit. +`Catalog.TooManyRenditions` for an update above the 64 rendition limit, and +`Catalog.EscapingBroadcast` for a `broadcast` reference that walks above the +handle's `path`. `Hang.Timeline.Consumer.subscribe(broadcast, root.archive)` reads segment `push`, `pop`, and `skip` events when a root advertises an archive. diff --git a/doc/lib/js/json.md b/doc/lib/js/json.md index 8b9b0456c1..a7f56953d1 100644 --- a/doc/lib/js/json.md +++ b/doc/lib/js/json.md @@ -33,4 +33,7 @@ for await (const value of consumer) { } ``` +A value is stamped when written, unless you pass its capture time: +`producer.update(value, at)`. + The Rust twin is [`moq-json`](/lib/rs/moq-json). diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 25a2370efa..4bd5178c76 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -56,7 +56,7 @@ for (;;) { - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. -- **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. +- **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. A `Broadcast.Consumer` names its broadcast by `path`: the path it was requested at relative to the origin handle's root, or empty for a standalone broadcast. Those references resolve against it. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. The [path pattern](/concept/moq-lite#path-patterns) grammar lives on the concept page. diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 2668c37f22..c367f8d2ed 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -54,7 +54,7 @@ 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. +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. On a video track, resume with a keyframe: a delta frame before it fails. The three advertising operations: `moq.createBroadcast(path)` (or `origin.createBroadcast`) returns an unannounced producer, invisible to everyone; diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 7eb3fc1390..47c153fd29 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -74,7 +74,7 @@ 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. +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. On a track from `publish_video` or `publish_video_on_track`, resume with a keyframe: a delta frame before it fails. The three advertising operations, as the other bindings spell them: `client.create_broadcast(path)` (or `OriginProducer.create_broadcast`) returns diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index db7bfb2e24..6375356ca5 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -13,10 +13,10 @@ Everything a party needs to ask for or answer an authorization on answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. -- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. -- **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline: future expiries are unchanged, and one already less than five seconds late gets the remainder of that skew window. +- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `mounts` that read a subtree from elsewhere on the origin, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired; expiry is exact, with no grace for clock skew. +- **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline; an expiry already past is now. - **Client**: `Client::new(url, tls)` and `Client::connect(request)` drive a lease against an auth server over `https://`, `unix://`, or loopback `http://`: revalidate on cadence with jittered backoff through an outage until `expires`, revoke on a 401/403 or an invalid grant, and POST `end` with the reason, duration, and byte totals the session reported through `lease::Consumer::close` when it ended. Dropping the consumer reports zero bytes. `end.reason` is `dropped`, `expired`, `refused`, `invalid`, `narrowed`, `shutdown`, or the session's own classification. -- **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a `jwt` in the query verified against a key file, a `{kid}.jwk` directory, or a JWK Set file, an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. +- **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a JWT from the `jwt` query or a type-0 SETUP token, verified against a key file, a `{kid}.jwk` directory, or a JWK Set file (a token of another type, or two different ones, is refused), an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. - **Keys**: generate HS256/384/512, RS256/384/512, PS256/384/512, ES256/384, or EdDSA keys as JWKs, with a `kid` for rotation and an optional immutable scope that caps every token the key signs. - **Claims**: `root`, `publish`, `subscribe`, `exp`, `iat`. Grants are [`Pattern`](https://docs.rs/moq-pattern) unions: `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. `Key::sign` and `Key::verify` handle the signature and expiry; `Key::decode::` checks only signature, algorithm, and key ID before returning an application payload. Legacy `put`/`get` prefix claims and scopes read as subtrees (`p` is `p/**`), and grants that are all subtrees are written that way so older verifiers accept them. - **Authorization**: `Claims::authorize(path)` scopes verified claims to the path a client dialed and returns the publish and subscribe patterns relative to it, exactly as `moq auth serve` does. The relay forwards the raw path and enforces the grant it gets. diff --git a/doc/lib/rs/moq-binary.md b/doc/lib/rs/moq-binary.md index 23fd506862..f1b9a0d6e3 100644 --- a/doc/lib/rs/moq-binary.md +++ b/doc/lib/rs/moq-binary.md @@ -28,5 +28,9 @@ let mut producer = moq_binary::snapshot::Producer::new(track, config); producer.update(payload)?; ``` +A payload is stamped when written, unless it carries its capture time: +`moq_net::Timed::from(bytes).at(captured)`. Writes return the encoded frame +size. + The TypeScript twin is [`@moq/binary`](/lib/js/binary). API: [docs.rs/moq-binary](https://docs.rs/moq-binary). diff --git a/doc/lib/rs/moq-json.md b/doc/lib/rs/moq-json.md index a079bc46de..40e8ef812f 100644 --- a/doc/lib/rs/moq-json.md +++ b/doc/lib/rs/moq-json.md @@ -28,6 +28,10 @@ let mut producer = moq_json::snapshot::Producer::new(track, config); producer.update(&value)?; ``` +A value is stamped when written, unless it carries its capture time: +`moq_net::Timed::from(&value).at(captured)`. Writes return the encoded frame +size, and an unchanged snapshot `update` returns `None`. + A snapshot producer also edits in place, so independent owners each touch only their own keys instead of clobbering one another. `mutate(|value| ...)` runs a closure and publishes the result, matching `Producer.mutate` in TypeScript; diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index be641e9619..eb1fd59e7b 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -85,6 +85,17 @@ The producer sets the entry's `mode` and encodes the track with its `compression`. Read it back from `Catalog` and subscribe with `catalog::Entry::new(name, &entry.binary)`. +A payload that knows when it was captured (a datagram's arrival, a sensor read) +carries that `Instant`. The producer maps it onto the broadcast clock and writes +it as the frame timestamp, and the entry advertises `jitter` and `delay` the way +a media rendition does, so telemetry lagging its video shows up as `delay`. An +instant ahead of now is refused. A device's own clock is an unrelated epoch; +keep it in the payload. + +```rust +telemetry.append(moq_net::Timed::from(packet).at(received_at))?; +``` + The fMP4, MPEG-TS, and FLV importers publish the source's own timestamps unless built with `live()`, which translates them onto the catalog's broadcast clock: the first frame is live on arrival, every track of the input shares that one diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index ca38796654..7044999709 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -58,7 +58,7 @@ 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. +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. On a track from `publishVideo`, resume with a keyframe: a delta frame before it fails. The three advertising operations: `session.publish.createBroadcast(path:)` returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / diff --git a/doc/package.json b/doc/package.json index 5bd4adf433..7900d0cada 100644 --- a/doc/package.json +++ b/doc/package.json @@ -13,11 +13,11 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2", "vitepress": "^1.6.4", "vitepress-plugin-llms": "^1.14.0", - "wrangler": "^4.131.2", + "wrangler": "^4.135.0", "yaml": "^2.9.1" } } diff --git a/doc/setup/dev.md b/doc/setup/dev.md index b7a3de2ffa..d8d35f9ff8 100644 --- a/doc/setup/dev.md +++ b/doc/setup/dev.md @@ -26,6 +26,17 @@ Recipes default to the local relay at `http://localhost:4443`. Pass publishers and `just pub serve` use MPEG-TS, with one audio frame per PES to avoid batching latency. Use `just pub cmaf` only when testing fMP4/CMAF. +BBB publishes the original 720p video and a pre-encoded 360p rendition at +about 600 kbps. The player can switch between them as bandwidth or viewport +size changes, without encoding while publishing. Consumers that only support +one rendition get the 720p track. + +To reproduce the hosted SD asset, run `just pub encode-bbb-sd`, then +`just pub upload bbb-sd.mp4` with access to the video bucket. The encode keeps +the source frame timestamps and keyframes, and holds the final SD frame long +enough to match the source audio's loop period. `just pub check-bbb` verifies +both assets across three loops. Remove the local SD file before re-encoding. + ## Debugging ```bash diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index 99013372b3..12dfbcdb50 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -65,7 +65,11 @@ Other changes to a deployment: - **Grants are patterns, not prefixes.** `anon` is now exactly the broadcast `anon`; write `anon/**` for the subtree. This applies to `--auth-public`, TOML `public`, and the `[auth.public]` table, which is now - `public_subscribe` / `public_publish`. + `public_subscribe` / `public_publish`. A public or mTLS pattern with no + wildcard refuses to start, naming the subtree to write, rather than pick + one reading silently. The patterns are rooted at `/`, as in 0.14, so + `anon/**` admits a client dialed at `/anon` and refuses one dialed outside + `anon/`. Earlier 0.15 releases rooted them at the dialed path instead. - **Token grants are patterns.** JWT `publish` and `subscribe` claims are patterns, so a token granting `alice` covers only `alice`; sign `alice/**` instead. Existing `put`/`get` tokens and key scopes keep working as subtrees, @@ -74,11 +78,33 @@ Other changes to a deployment: the pattern-only `moq-auth` 0.1.0/0.1.1 or `@moq/auth` 0.1.x/0.2.0 refuse that form, so upgrade them before their issuers. Grants only a pattern can express need an upgraded verifier. +- **Removed auth settings refuse to start.** 0.14's `[auth]` `key`, `key_dir`, + `auth_api`, `domains`, `mtls_tier`, and `[auth.tls]`, and their flags and + `MOQ_AUTH_*` variables, stop the relay with the replacement named rather + than being ignored. +- **Token claims.** `iss`, `sub`, and `jti` are ignored and `nbf` is enforced. + Any other claim refuses the token with its name, `aud` and `cluster` + included, where 0.14 ignored all but `aud`: an issuer adding app claims such + as `user_id` must drop them. +- **Every credential is evaluated or refused.** A relay on `--auth-public` + refuses a session presenting a token, as 0.14 did, including peers sending + `cluster.token`, and refuses to start with a client CA. `moq auth serve` + refuses a session presenting both a JWT and a certificate, so a peer + presents one or the other. +- **`moq auth serve` never re-checks or expires by default**, as 0.14 never + did. `--revalidate` needs `--expires`, and `--limit-*` needs `--revalidate`. - **mTLS admits nothing on its own.** A verified client certificate is reported to the auth server, which grants it. `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` restores the old full access for every certificate the relay's client CA verifies, so keep that CA to cluster peers. - **`moq --listen` needs auth.** A CLI listener refuses to start without `--auth-url` or `--auth-public` instead of accepting everyone. +- **Other 0.14 auth differences kept.** A 0.14 peer that dials with a + cluster JWT no longer sees `.internal/origins` gossip; peers identify by + certificate or LAN path. An auth server's `root` alias may have any depth. + A path in `--cluster-connect` is not refused, although it shifts the mesh + frame. `moq auth serve --key` takes a file, not an https or JWKS URL. + `moq auth sign --root` is the token root and JS `verify --root` the dialed + path. `/.cluster*` roots are reserved. `--auth-public a,b` splits on commas. - **noq is the only QUIC stack** (#3811). The `quinn` and `quiche` cargo features and the backend setting are gone. - **Stats counters** are `*_started` / `*_ended` (`sessions_started`, diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 04fffe9264..8384c75140 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -391,6 +391,7 @@ type JsonSchema = { "broadcast": string | undefined, "bitrate": number | undefined, "jitter": number | undefined, + "delay": number | undefined, } ~~~ @@ -406,6 +407,7 @@ type BinarySchema = { "broadcast": string | undefined, "bitrate": number | undefined, "jitter": number | undefined, + "delay": number | undefined, } ~~~ @@ -461,9 +463,14 @@ A `snapshot` group covers a single value (plus any deltas), so its window spans ### broadcast {#data-shared} The `broadcast` field carries the same meaning here as it does for a media rendition ({{field-broadcast}}). -### bitrate and jitter {#data-estimates} +### bitrate, jitter, and delay {#data-estimates} The optional `bitrate` field is the track's maximum bitrate in bits per second. -The optional `jitter` field carries the same meaning and rules as it does for a media rendition ({{field-jitter}}), with a payload in place of a frame. +The optional `jitter` and `delay` fields carry the same meaning and rules as they do for a media rendition ({{field-jitter}}, {{field-delay}}), with a payload in place of a frame. + +A payload's presentation timestamp is its capture time on the broadcast's clock, the one its media renditions use, when the publisher knows it (a datagram's arrival, a sensor read), and otherwise the time the publisher wrote it. +A publisher measures `jitter` and `delay` only from payloads that carry a capture time, and MUST omit both for a track written without one. +A publisher MUST NOT use a timestamp on an unrelated clock, such as a device's own uptime, since it would report a meaningless lateness and, through the broadcast-wide minimum, distort every other rendition's `delay`. +A `snapshot` update that writes no frame, such as an unchanged JSON value, is not a flush and is not measured. ## Binary Fields {#binary} A decoder config field carrying raw bytes, notably `description` (an `AllowSharedBufferSource` in WebCodecs), is carried in the catalog as a hex string ({{!RFC4648, Section 8}}). @@ -1101,6 +1108,7 @@ A publisher MAY estimate an unknown final duration from the frame cadence, but M - Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. - Added the optional `delay` rendition field: how far a rendition's minimum flush lateness trails the broadcast's earliest rendition, never lowered once advertised and never subtracted across renditions. - Recommended namespaced keys for application root sections. +- Added the optional `delay` field to `json` and `binary` track entries, measured only from payloads stamped with their capture time on the broadcast clock. # Acknowledgments {:numbered="false"} diff --git a/flake.lock b/flake.lock index bf8e740574..73deb19a16 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1788465171, - "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "lastModified": 1789760033, + "narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=", "owner": "ipetkov", "repo": "crane", - "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd", "type": "github" }, "original": { @@ -35,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1788549839, - "narHash": "sha256-kOrCcSIA6w9J1hX5DqHy2k9pDTJymExTsbV74U9UtCA=", + "lastModified": 1790510107, + "narHash": "sha256-EVMNYv7hYDDD9TGVT/hIyTYgpiXA8y3m5xIEIxuGNU0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "17de0b976395537756f30a3e78f2f06e5cec89ed", + "rev": "3181085bfd08663b6b9e60bc7a8395c2aaa741bd", "type": "github" }, "original": { @@ -49,11 +49,42 @@ "type": "github" } }, + "quest": { + "inputs": { + "crane": [ + "crane" + ], + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ], + "rust-overlay": [ + "rust-overlay" + ] + }, + "locked": { + "lastModified": 1790617890, + "narHash": "sha256-k8uvr4/Pt5Hf+Adjv3sp84l6HLkpR5BSKHoeMj4cxRA=", + "owner": "kixelated", + "repo": "quest", + "rev": "46d7fe89247919583632e4963aee1c9a68dfe059", + "type": "github" + }, + "original": { + "owner": "kixelated", + "repo": "quest", + "rev": "46d7fe89247919583632e4963aee1c9a68dfe059", + "type": "github" + } + }, "root": { "inputs": { "crane": "crane", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", + "quest": "quest", "rust-overlay": "rust-overlay" } }, @@ -64,11 +95,11 @@ ] }, "locked": { - "lastModified": 1788678114, - "narHash": "sha256-pcqbpV4ZI79al6KAlr+WJjaEo/PYfgctRlG43D5BSJM=", + "lastModified": 1789889921, + "narHash": "sha256-aL/ogiN7qZCGeNovS1f9hX73jwYIKb4Pcq6AZm57WtY=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "4748ec2f5ed4a881474ed4c98aa71a5308cdac8d", + "rev": "1fb104a12a8667045559b2575d6d448ae2fbd99b", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 0efc9eea82..b3b2168be9 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,15 @@ url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; + # The quest CLI, which also serves the quest guide and skills the stubs in + # .claude/skills call. Bump the rev to upgrade them. + quest = { + url = "github:kixelated/quest/46d7fe89247919583632e4963aee1c9a68dfe059"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + inputs.crane.follows = "crane"; + inputs.rust-overlay.follows = "rust-overlay"; + }; }; outputs = @@ -33,6 +42,7 @@ flake-utils, crane, rust-overlay, + quest, ... }: let @@ -64,8 +74,12 @@ # string pool 4-byte aligned, which macOS 27's dyld refuses to load, so # release-profile proc macros and cdylibs are a coin flip there. The # crates still declare their own lower floors (Cargo.toml rust-version). - rust-toolchain = pkgs.rust-bin.stable."1.98.1".default.override { + rust-toolchain = pkgs.rust-bin.stable."1.98.1".minimal.override { + # `minimal` rather than `default`, which adds 740 MB of offline HTML + # docs to a closure every CI job downloads. extensions = [ + "rustfmt" + "clippy" "rust-src" "rust-analyzer" ]; @@ -382,7 +396,8 @@ # Type-checking needs headers rather than libraries, and those are # cross-platform -- obs-headers above, plus qt6.qtbase, which does build # on Darwin. So `just obs compile` and the lints run everywhere while - # `just obs build` stays native. + # `just obs build` stays native. On Linux it links nixpkgs' obs-studio, + # which only the `.#obs` shell below carries. obsDeps = with pkgs; [ @@ -399,7 +414,6 @@ gersemi ] ++ lib.optionals (!stdenv.hostPlatform.isDarwin) [ - obs-studio ninja ]; @@ -465,6 +479,15 @@ # are disallowed in the flake `packages` schema. legacyPackages = { inherit (pkgs) gst_all_1; + + # `nix develop .#obs`: the default shell plus obs-studio, which linking + # the plugin on Linux needs (`just obs build`, `just obs ci`). Kept out + # of the default shell because it pulls in ~3 GB (CEF, mostly) that + # every other CI job would download. Under legacyPackages rather than + # devShells so `nix flake check` doesn't build it on every Rust PR. + obs = self.devShells.${system}.default.overrideAttrs (old: { + nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.obs-studio ]; + }); }; devShells.default = pkgs.mkShell { @@ -480,7 +503,8 @@ ++ ktDeps ++ goDeps ++ dartDeps - ++ devTools; + ++ devTools + ++ [ quest.packages.${system}.default ]; # jemalloc's configure uses -O0 test builds, which conflict with # Nix's _FORTIFY_SOURCE hardening (requires -O). @@ -536,14 +560,6 @@ # (`.github/actions/rust-cache`); nothing here configures it. checks = { package-source-assets = pkgs.runCommand "package-source-assets" { } '' - for asset in \ - rs/moq-c/moq-c.pc.in \ - rs/moq-c/native-libs/apple.txt \ - rs/moq-c/native-libs/linux.txt \ - rs/moq-c/native-libs/windows.txt - do - test -f "${overlayPkgs.moq-c.src}/$asset" - done test -f "${overlayPkgs.moq-boy.src}/rs/moq-video/src/frame/nv12_resize.ptx" touch "$out" ''; diff --git a/go/wrapper/README.md b/go/wrapper/README.md index a723411f70..b3743abc41 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -119,7 +119,7 @@ deliver them, and there is no stream fallback. ## Versioning -`VERSION` holds the human-owned `MAJOR.MINOR` line (the wrapper API version). Bump it in a PR when the wrapper's own API changes. The patch number is derived by CI from the existing mirror tags, so every release (whether triggered by a wrapper change or by a new `moq.dev/moq-ffi`) just takes the next patch on that line. +`VERSION` holds the human-owned `MAJOR.MINOR` line (the wrapper API version). Bump it in a PR only for a breaking change to the wrapper's own API. The patch number is derived by CI from the existing mirror tags, so every release (whether triggered by a wrapper change or by a new `moq.dev/moq-ffi`) just takes the next patch on that line. The committed `go.mod` carries a `require moq.dev/moq-ffi v0.0.0` **placeholder**. Do not "fix" it or add a `replace`: `just go check` injects a local `replace` to the freshly-generated bindings, and CI rewrites the `require` to the latest published `moq.dev/moq-ffi` at release time. Because Go resolves to the maximum version across the build graph, that `require` is a floor. Consumers always get an ffi at least as new as the wrapper was built against. diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 781e6c5b98..f43c83f4cc 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -18,6 +18,15 @@ import ( // job instead of hanging it. const testTimeout = 10 * time.Second +// newOrigin returns an origin that lasts the whole test. An OriginProducer has +// no Close: the collector ends its origin once nothing reaches the producer, +// even while consumers and dynamic handles made from it are still in use. +func newOrigin(t *testing.T) *moq.OriginProducer { + origin := moq.NewOriginProducer() + t.Cleanup(func() { runtime.KeepAlive(origin) }) + return origin +} + // opusHead builds a valid OpusHead init buffer (RFC 7845): 48 kHz, 2 channels. func opusHead() []byte { buf := []byte("OpusHead") @@ -30,7 +39,7 @@ func opusHead() []byte { } func TestOriginLifecycle(t *testing.T) { - origin := moq.NewOriginProducer() + origin := newOrigin(t) _ = origin.Consume() dynamic, err := origin.Dynamic("", moq.Route{}) if err != nil { @@ -43,7 +52,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) dynamic, err := origin.Dynamic("", moq.Route{}) if err != nil { t.Fatal(err) @@ -263,7 +272,7 @@ func TestDecodeVideoFrame(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) broadcast, err := origin.CreateBroadcast("video-decode-frame") if err != nil { t.Fatal(err) @@ -454,7 +463,7 @@ func TestLocalPublishConsumeAudio(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) broadcast, err := origin.CreateBroadcast("live") if err != nil { t.Fatal(err) @@ -1029,7 +1038,7 @@ func TestConsumerCancelConcurrent(t *testing.T) { // dynamic handler, then proves the origin still resolves: the cancel has to abort // that one request rather than the consumer it was made on. func TestRequestBroadcastCancelKeepsTheOrigin(t *testing.T) { - origin := moq.NewOriginProducer() + origin := newOrigin(t) dynamic, err := origin.Dynamic("", moq.Route{}) if err != nil { t.Fatal(err) @@ -1286,7 +1295,7 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) broadcast, err := origin.CreateBroadcast("live") if err != nil { t.Fatal(err) @@ -1339,7 +1348,7 @@ func TestAnnouncedPatternCaptures(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) filter := "*/chat" announced, err := origin.Consume().Announced(moq.AnnounceOptions{Prefix: "room", Filter: &filter}) if err != nil { @@ -1377,7 +1386,7 @@ func TestAnnouncedExactFilterCapturesEmpty(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) filter := "" announced, err := origin.Consume().Announced(moq.AnnounceOptions{Prefix: "room/alice/chat", Filter: &filter}) if err != nil { @@ -1408,7 +1417,7 @@ func TestAnnouncedYieldsLiveOnceCaughtUp(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) consumer := origin.Consume() empty, err := consumer.Announced(moq.AnnounceOptions{}) @@ -1494,7 +1503,7 @@ func TestDynamicServesARequestUnderAPrefix(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - origin := moq.NewOriginProducer() + origin := newOrigin(t) dynamic, err := origin.Dynamic("live", moq.Route{}) if err != nil { t.Fatal(err) diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index ca1b87c021..7a60178f78 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -11,6 +11,10 @@ import ( // OriginProducer publishes broadcasts under paths and hands out consumers that // discover them. Wire one as both a client's/server's publish source and // consume sink for a full-duplex peer. +// +// There is no Close: the origin ends once the collector reaches every +// producer, and its consumers and dynamic handles then fail with [ErrClosed]. +// Keep a producer reachable for as long as the origin should live. type OriginProducer struct { inner *ffi.MoqOriginProducer } diff --git a/go/wrapper/origin_internal_test.go b/go/wrapper/origin_internal_test.go new file mode 100644 index 0000000000..bd46a0914c --- /dev/null +++ b/go/wrapper/origin_internal_test.go @@ -0,0 +1,34 @@ +package moq + +import ( + "context" + "errors" + "testing" + "time" +) + +// An OriginProducer has no Close: the collector ends its origin by finalizing +// the last producer, even while a consumer or dynamic handle made from it is in +// use. Destroying the handle is what that finalizer does, so this pins the +// behavior the doc comment warns about without waiting on the collector. +func TestOriginEndsWithItsProducer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + origin := NewOriginProducer() + dynamic, err := origin.Dynamic("", Route{}) + if err != nil { + t.Fatal(err) + } + defer dynamic.Cancel() + consumer := origin.Consume() + + origin.inner.Destroy() + + if _, err := dynamic.RequestedBroadcast(ctx); !errors.Is(err, ErrClosed) { + t.Fatalf("RequestedBroadcast err = %v, want ErrClosed", err) + } + if _, err := consumer.RequestBroadcast(ctx, "live"); !errors.Is(err, ErrClosed) { + t.Fatalf("RequestBroadcast err = %v, want ErrClosed", err) + } +} diff --git a/infra/apt/bun.lock b/infra/apt/bun.lock index b7b6e1f8f6..201d5b4eeb 100644 --- a/infra/apt/bun.lock +++ b/infra/apt/bun.lock @@ -16,17 +16,17 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260915.1", "", {}, "sha512-3Lw2wkyVcEDdD0W6rNBmannaAkUsWGsO5TjbyHflWIDlhxtFRDFnxqHoDlg4YiVxinsIcBl1j9jW1kdruUhZpg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260920.1", "", {}, "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -208,7 +208,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -228,9 +228,9 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], diff --git a/infra/rpm/bun.lock b/infra/rpm/bun.lock index 10c9b93f67..c1a90063a2 100644 --- a/infra/rpm/bun.lock +++ b/infra/rpm/bun.lock @@ -16,17 +16,17 @@ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260911.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260911.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260911.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260911.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260911.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260915.1", "", {}, "sha512-3Lw2wkyVcEDdD0W6rNBmannaAkUsWGsO5TjbyHflWIDlhxtFRDFnxqHoDlg4YiVxinsIcBl1j9jW1kdruUhZpg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260920.1", "", {}, "sha512-wP7wbKqfQjFRGhE/tvu7bwvmBBTqJjvrETTvNzF4u5oolVEXt0gy6JxbqcUC2Khz0Cy+M/q7rhOOODng8VpnPw=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -208,7 +208,7 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "miniflare": ["miniflare@5.20260911.1-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-7IDj9monoYcCPrS8HfcTt90T3pDwKGvNEAR1Y061KbJhBd5JkStOwOpHMUDFBo9PEbjWVDxPICAnXGNNV2LNfQ=="], + "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -228,9 +228,9 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "workerd": ["workerd@1.20260911.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260911.1", "@cloudflare/workerd-darwin-arm64": "1.20260911.1", "@cloudflare/workerd-linux-64": "1.20260911.1", "@cloudflare/workerd-linux-arm64": "1.20260911.1", "@cloudflare/workerd-windows-64": "1.20260911.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ=="], + "workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - "wrangler": ["wrangler@4.131.2", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260911.1-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260911.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260911.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-jmkGE7monbPKyYQr1FPQN+SARVhddqw2fhXOmTKCw4lroqlFGSS6rit/RTvPi/qzNLKrXxkS8DhWXasJnStplg=="], + "wrangler": ["wrangler@4.135.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], diff --git a/js/auth/package.json b/js/auth/package.json index 60f2a21c2d..7c930f97e4 100644 --- a/js/auth/package.json +++ b/js/auth/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "rimraf": "^6.1.3", "typescript": "7.0.2" } diff --git a/js/auth/src/claims.ts b/js/auth/src/claims.ts index 493690b947..d22e594d64 100644 --- a/js/auth/src/claims.ts +++ b/js/auth/src/claims.ts @@ -70,6 +70,17 @@ const ClaimsFields = { exp: z.optional(z.int()), /** Issued-at time, as a whole unix timestamp in seconds. */ iat: z.optional(z.int()), + /** Not-before time, as a whole unix timestamp in seconds. Enforced by verification. */ + nbf: z.optional(z.int()), +}; + +// Registered claims that narrow nothing: an issuer's bookkeeping, read and dropped. +// Every other claim is refused, since an unknown one may narrow the grant, and a +// misspelled `root` would otherwise widen it to everything. +const IgnoredFields = { + iss: z.optional(z.unknown()), + sub: z.optional(z.unknown()), + jti: z.optional(z.unknown()), }; const PrefixListSchema = z.union([z.string(), z.array(z.string())]); @@ -82,12 +93,18 @@ const PrefixListSchema = z.union([z.string(), z.array(z.string())]); * exactly what it says: `alice` is one broadcast, `alice/**` is a subtree, and `**` is * everything under the root. Legacy `moq-token` claims read too, each `put`/`get` * prefix `p` as the subtree `p/**`, and signing writes that form whenever it says the - * same thing. Any other field fails verification. + * same thing. The registered `iss`, `sub`, and `jti` claims are read and dropped; any + * other field fails verification. */ export const ClaimsSchema = z .pipe( - z.strictObject({ ...ClaimsFields, put: z.optional(PrefixListSchema), get: z.optional(PrefixListSchema) }), - z.transform(decodeGrants), + z.strictObject({ + ...ClaimsFields, + ...IgnoredFields, + put: z.optional(PrefixListSchema), + get: z.optional(PrefixListSchema), + }), + z.transform(({ iss: _iss, sub: _sub, jti: _jti, ...claims }, ctx) => decodeGrants(claims, ctx)), ) .check( // Emptiness, not just presence: `publish: []` grants nothing, and the Rust crate diff --git a/js/auth/src/contract.test.ts b/js/auth/src/contract.test.ts index 3aed509742..aad28a2115 100644 --- a/js/auth/src/contract.test.ts +++ b/js/auth/src/contract.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { GrantSchema, RequestSchema } from "./contract.ts"; +import { GrantSchema, RequestSchema, TokenSchema } from "./contract.ts"; // The fixtures below are what the Rust `moq_auth::Request` and `Grant` serialize to, // so a server written against these schemas reads what a relay sends. @@ -52,6 +52,35 @@ test("an end request carries its facts beside the rest", () => { expect(invalid.reason).toBe("invalid"); }); +test("a SETUP token parses as the Rust vector", () => { + // What `moq_auth::Request` serializes a CAT of bytes 00 fb ff to. + const request = RequestSchema.parse( + JSON.parse( + '{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}', + ), + ); + expect(request.token).toEqual({ kind: 1, value: "APv_" }); + + expect(() => + RequestSchema.parse({ + id: "1", + event: "connect", + node: "n", + transport: "quic", + path: "/", + token: { kind: 0, value: "AP+/" }, + }), + ).toThrow(); + + // Not a length or final character any byte string encodes to, which Rust refuses too. + for (const value of ["A", "AB", "APv_A"]) { + expect(() => TokenSchema.parse({ kind: 0, value })).toThrow(); + } + for (const value of ["", "AA", "AAA", "AAAA", "AQ", "AAE"]) { + expect(TokenSchema.parse({ kind: 0, value }).value).toBe(value); + } +}); + test("a connect must not carry end facts, and an unknown transport is refused", () => { expect(() => RequestSchema.parse({ id: "1", event: "end", node: "n", transport: "quic", path: "/" })).toThrow(); expect(() => diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index 5db8741d9d..19b31a9603 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -36,6 +36,17 @@ export const PeerSchema = z.object({ }); export type Peer = z.infer; +/** A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. */ +export const TokenSchema = z.object({ + /** The moq-transport Token Type: 0 is negotiated out of band (a JWT to `moq auth serve`), 1 is a Common Access Token. */ + kind: z.int().check(z.nonnegative()), + /** The token bytes, base64url without padding. The final character must leave no stray bits, as Rust decodes it. */ + value: z + .string() + .check(z.regex(/^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$/)), +}); +export type Token = z.infer; + /** Byte totals for a session, both directions from the relay's point of view. */ export const BytesSchema = z.object({ /** Bytes the relay sent to the peer. */ @@ -64,6 +75,8 @@ const BaseRequestSchema = z.object({ path: z.string(), /** The raw query string, without the leading `?`. */ query: z.optional(z.string()), + /** The credential a moq-transport client presented in its SETUP. */ + token: z.optional(TokenSchema), /** The direction the client declared at SETUP; absent means both. */ role: z.optional(RoleSchema), /** The verified client certificate, when one was presented. */ @@ -73,8 +86,8 @@ const BaseRequestSchema = z.object({ /** * Everything a relay knows about a session, sent to the auth server on every event. * - * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path` - * and `query`, so no query parameter is special. The same shape carries every event; + * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path`, + * `query`, and `token`, so no query parameter is special. The same shape carries every event; * an `end` adds what the session did. */ export const RequestSchema = z.discriminatedUnion("event", [ @@ -114,6 +127,8 @@ export const GrantSchema = z subscribe: z.optional(PatternListSchema), /** The path the patterns are relative to, replacing the dialed one. Absent means the dialed path. */ root: z.optional(z.string()), + /** Subtrees read from elsewhere: each path, relative to the root, resolves at the absolute path it maps to. Read-only. */ + mounts: z.optional(z.record(z.string(), z.string())), /** When the session closes, as whole unix seconds. */ expires: z.optional(z.int()), /** How long until the relay asks again, in whole seconds. Zero would be a tight loop. */ diff --git a/js/auth/src/key.test.ts b/js/auth/src/key.test.ts index 6224ced2ab..663cfa4f18 100644 --- a/js/auth/src/key.test.ts +++ b/js/auth/src/key.test.ts @@ -777,3 +777,37 @@ test("key scope treats absolute and rooted grants alike", async () => { // Roles are independent: a publish-only scope grants no subscribe. await expect(Key.sign(scoped, { root: "project", subscribe: ["live/**"] })).rejects.toThrow("exceed the key scope"); }); + +/** Sign an arbitrary payload with `testKey`, bypassing the claims schema, as another issuer might. */ +async function signRaw(payload: Record): Promise { + const secret = new TextEncoder().encode("test-secret-that-is-long-enough-for-hmac-sha256"); + return new SignJWT(payload).setProtectedHeader({ alg: "HS256", typ: "JWT", kid: testKey.kid }).sign(secret); +} + +// An issuer's bookkeeping is read and dropped; anything else is refused by name, since it +// might narrow the grant, and a misspelled `root` would widen it to everything. +test("verify - only registered claims", async () => { + const key = Key.parse(encodeJwk(testKey)); + const now = Math.floor(Date.now() / 1000); + + const token = await signRaw({ root: "room", publish: ["**"], iss: "api", sub: "alice", jti: "1", iat: now }); + const claims = await Key.verify(key, token); + expect(claims.root).toBe("room"); + expect(claims).not.toHaveProperty("iss"); + + for (const [claim, payload] of [ + ["rooot", { rooot: "room/123", publish: ["**"] }], + ["user_id", { root: "room", publish: ["**"], user_id: 7 }], + ["cluster", { root: "room", put: [""], cluster: true }], + ["aud", { root: "room", publish: ["**"], aud: "relay" }], + ] as const) { + await expect(Key.verify(key, await signRaw(payload))).rejects.toThrow(claim); + } +}); + +test("verify - enforces not before", async () => { + const key = Key.parse(encodeJwk(testKey)); + const now = Math.floor(Date.now() / 1000); + expect((await Key.verify(key, await signRaw({ publish: ["**"], nbf: now - 60 }))).nbf).toBe(now - 60); + await expect(Key.verify(key, await signRaw({ publish: ["**"], nbf: now + 3600 }))).rejects.toThrow(); +}); diff --git a/js/auth/src/key.ts b/js/auth/src/key.ts index 17b29bf3ea..120d9f657d 100644 --- a/js/auth/src/key.ts +++ b/js/auth/src/key.ts @@ -220,7 +220,7 @@ async function decode(key: PublicKey | SymmetricKey, token: string): Promise { const payload = await decode(key, token); let claims: Claims; @@ -229,7 +229,9 @@ async function verify(key: PublicKey | SymmetricKey, token: string): Promise now) throw new Error("Token is not yet valid"); ensureClaimsWithinScope(key, claims); return claims; } diff --git a/js/binary/src/snapshot/producer.ts b/js/binary/src/snapshot/producer.ts index 8b4e1e66f5..75f4fe3881 100644 --- a/js/binary/src/snapshot/producer.ts +++ b/js/binary/src/snapshot/producer.ts @@ -38,8 +38,10 @@ export class Producer { * * Unlike `@moq/json`, an identical value is republished rather than skipped: comparing two * opaque blobs costs a full scan, and only the caller knows whether its bytes changed. + * + * `at` is when the payload was captured, written as its frame timestamp. Defaults to now. */ - update(payload: Uint8Array): void { + update(payload: Uint8Array, at: Time.Timestamp = Time.Timestamp.now()): void { // Consumers all decode with `@moq/flate`'s default cap, so publishing past it would advertise // a value that always fails to read. Rejected before anything is published; unlike a stream // this is not terminal, since the previous value still stands and the next update supersedes. @@ -57,7 +59,7 @@ export class Producer { const group = this.#track.appendGroup(); try { - group.writeFrame({ payload: encoded, timestamp: Time.Timestamp.now() }); + group.writeFrame({ payload: encoded, timestamp: at }); } finally { // The group is already visible on the track, so leaving it open on a failed write would // strand a subscriber that advanced into it with nothing to read and no end. diff --git a/js/binary/src/snapshot/snapshot.test.ts b/js/binary/src/snapshot/snapshot.test.ts index bee50c6925..19a68aac19 100644 --- a/js/binary/src/snapshot/snapshot.test.ts +++ b/js/binary/src/snapshot/snapshot.test.ts @@ -138,3 +138,14 @@ test("an aborted track surfaces its error instead of spinning", async () => { await expect(consumer.next()).rejects.toThrow("subscription aborted"); }); + +test("a capture timestamp is written as the frame timestamp", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const captured = Time.Timestamp.fromMillis(1_234); + producer.update(bytes(1), captured); + producer.finish(); + + const frame = await (await track.subscribe().ordered().nextGroup())?.readFrame(); + expect(frame?.timestamp.as(Time.Timescale.MILLI)).toBe(1_234); +}); diff --git a/js/binary/src/stream/producer.ts b/js/binary/src/stream/producer.ts index 13f6768153..8605954f6b 100644 --- a/js/binary/src/stream/producer.ts +++ b/js/binary/src/stream/producer.ts @@ -40,8 +40,10 @@ export class Producer { * log this mode promises, so the failure is surfaced rather than papered over with a second * group. The group is aborted rather than closed cleanly, so a consumer sees the failure * instead of a log that merely looks complete. Every later append fails on the closed track. + * + * `at` is when the payload was captured, written as its frame timestamp. Defaults to now. */ - append(payload: Uint8Array): void { + append(payload: Uint8Array, at: Time.Timestamp = Time.Timestamp.now()): void { // A payload no consumer could decode is as terminal as one the track rejects: consumers all // decode with `@moq/flate`'s default cap, so this would publish a record none of them could // read. Ends the track like any other lost record, and aborts the group the same way the @@ -62,7 +64,7 @@ export class Producer { const encoded = this.#flate ? this.#flate.frame(payload) : payload; try { - this.#group.writeFrame({ payload: encoded, timestamp: Time.Timestamp.now() }); + this.#group.writeFrame({ payload: encoded, timestamp: at }); } catch (err) { // The payload never reached the wire, so the log has a hole in it, which is not the // lossless log this mode promises. Continuing into a second group would hand consumers a diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index 4b21ace239..44a2430506 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -185,3 +185,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => subscriber.close(); producer.finish(); }); + +test("each record keeps its capture timestamp", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + producer.append(new Uint8Array([1]), Time.Timestamp.fromMillis(1_000)); + producer.append(new Uint8Array([2]), Time.Timestamp.fromMillis(2_000)); + producer.finish(); + + const group = await track.subscribe().ordered().nextGroup(); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(1_000); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(2_000); +}); diff --git a/js/clock/package.json b/js/clock/package.json index 6444a36234..74e2c9e7a8 100644 --- a/js/clock/package.json +++ b/js/clock/package.json @@ -26,7 +26,7 @@ "@fails-components/webtransport-transport-http3-quiche": "^1.6.8" }, "devDependencies": { - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "typescript": "7.0.2" } } diff --git a/js/common/declarations.test.ts b/js/common/declarations.test.ts new file mode 100644 index 0000000000..79e3b8b328 --- /dev/null +++ b/js/common/declarations.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { problems } from "./declarations"; + +const root = "/dist"; + +test("an import of a stripped export is reported", () => { + const files = new Map([ + ["/dist/reload.d.ts", "export declare class Reload {}\n"], + ["/dist/index.d.ts", 'import { ReloadDelay } from "./reload.js";\nexport declare const delay: ReloadDelay;\n'], + ]); + expect(problems(root, files)).toEqual([ + "index.d.ts: imports ReloadDelay from ./reload.js, which does not export it", + ]); +}); + +test("an import of a file with no declarations is reported", () => { + const files = new Map([["/dist/index.d.ts", 'import type { Mock } from "./mock.ts";\n']]); + expect(problems(root, files)).toEqual(["index.d.ts: imports ./mock.ts, which emitted no declarations"]); +}); + +test("names re-exported through a star, an alias, a default, or a directory index resolve", () => { + const files = new Map([ + ["/dist/inner.d.ts", "export interface Delay {}\ndeclare const status = 1;\nexport { status as Status };\n"], + ["/dist/outer/index.d.ts", 'export * from "../inner.js";\n'], + ["/dist/element.d.ts", "export default class Element {}\n"], + ["/dist/page.d.ts", 'import { Delay } from "./outer";\nimport { Status } from ".";\n'], + [ + "/dist/index.d.ts", + 'export * from "./outer";\nexport type { default as Element } from "./element.tsx";\nimport { type Delay, Status as S } from "./outer/index.ts";\nimport { Other } from "@moq/other";\nimport icon from "./icon.svg?raw";\n', + ], + ]); + expect(problems(root, files)).toEqual([]); +}); + +test("a default import of a module without a default export is reported", () => { + const files = new Map([ + ["/dist/element.d.ts", "export declare class Element {}\n"], + ["/dist/index.d.ts", 'import type Element from "./element.js";\nexport { Element };\n'], + ]); + expect(problems(root, files)).toEqual(["index.d.ts: imports default from ./element.js, which does not export it"]); +}); + +test("type-only star re-exports, const enums, and let declarations resolve", () => { + const files = new Map([ + ["/dist/types.d.ts", "export declare const enum State { Open }\nexport declare let value: number;\n"], + ["/dist/outer.d.ts", 'export type * from "./types.js";\n'], + ["/dist/index.d.ts", 'import type { State, value } from "./outer.js";\n'], + ]); + expect(problems(root, files)).toEqual([]); +}); diff --git a/js/common/declarations.ts b/js/common/declarations.ts new file mode 100644 index 0000000000..f7e9d21ba5 --- /dev/null +++ b/js/common/declarations.ts @@ -0,0 +1,128 @@ +// Checks a package's emitted `.d.ts` files for imports the target file no longer exports. +// +// `stripInternal` drops an `@internal` export from its own `.d.ts` but leaves the import in any +// file that names the type, so a published consumer sees a module that does not export it. Only +// declaration emit shows this, which is why it runs over `dist/` after the build rather than as a +// test that would have to run the compiler again. + +import { dirname, extname, join, relative, resolve } from "node:path"; +import { parse } from "@babel/parser"; + +type Statement = ReturnType["program"]["body"][number]; +type Declaration = Extract["declaration"]; + +type FileExports = { + names: Set; + stars: string[]; + imports: Array<{ specifier: string; names: string[] }>; +}; + +/** Every import in `files` (path to `.d.ts` source) that its relative target does not export. */ +export function problems(root: string, files: Map): string[] { + const parsed = new Map(); + for (const [file, source] of files) { + parsed.set(file, fileExports(source)); + } + + const found: string[] = []; + for (const [file, info] of parsed) { + for (const { specifier, names } of info.imports) { + const target = dtsPath(parsed, file, specifier); + if (!target) continue; + + if (!parsed.has(target)) { + found.push(`${relative(root, file)}: imports ${specifier}, which emitted no declarations`); + continue; + } + + for (const name of names) { + if (!exported(parsed, target, name)) { + found.push(`${relative(root, file)}: imports ${name} from ${specifier}, which does not export it`); + } + } + } + } + return found; +} + +// The declaration file a relative specifier resolves to, as a file or a directory index. A package +// specifier resolves outside the package, and an asset (`./icon.svg?raw`) is typed by the bundler, +// so neither is checked. +function dtsPath(files: Map, from: string, specifier: string): string | undefined { + if (!specifier.startsWith(".")) return undefined; + const ext = extname(specifier); + if (ext && !/^\.(js|jsx|ts|tsx)$/.test(ext)) return undefined; + const base = resolve(dirname(from), specifier).replace(/\.(js|jsx|ts|tsx)$/, ""); + const index = join(base, "index.d.ts"); + return files.has(index) ? index : `${base}.d.ts`; +} + +function exported(files: Map, file: string, name: string, seen = new Set()): boolean { + if (seen.has(file)) return false; + seen.add(file); + + const info = files.get(file); + if (!info) return false; + if (info.names.has(name)) return true; + + for (const specifier of info.stars) { + const target = dtsPath(files, file, specifier); + if (target && exported(files, target, name, seen)) return true; + } + return false; +} + +function fileExports(source: string): FileExports { + const names = new Set(); + const stars: string[] = []; + const imports: Array<{ specifier: string; names: string[] }> = []; + + const ast = parse(source, { sourceType: "module", plugins: [["typescript", { dts: true }]] }); + for (const statement of ast.program.body) { + switch (statement.type) { + case "ImportDeclaration": + if (statement.specifiers.length === 0) break; + imports.push({ + specifier: statement.source.value, + names: statement.specifiers.flatMap((s) => { + if (s.type === "ImportDefaultSpecifier") return ["default"]; + if (s.type === "ImportSpecifier") return [moduleName(s.imported)]; + return []; + }), + }); + break; + case "ExportNamedDeclaration": { + const imported: string[] = []; + for (const s of statement.specifiers) { + names.add(moduleName(s.exported)); + if (s.type === "ExportSpecifier") imported.push(moduleName(s.local)); + } + if (statement.source) imports.push({ specifier: statement.source.value, names: imported }); + for (const name of declared(statement.declaration)) names.add(name); + break; + } + case "ExportAllDeclaration": + stars.push(statement.source.value); + break; + case "ExportDefaultDeclaration": + names.add("default"); + break; + } + } + + return { names, stars, imports }; +} + +function moduleName(node: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string }): string { + return node.type === "Identifier" ? node.name : node.value; +} + +// The names an `export declare ...` statement introduces. +function declared(declaration: Declaration | null | undefined): string[] { + if (!declaration) return []; + if (declaration.type === "VariableDeclaration") { + return declaration.declarations.flatMap((d) => (d.id.type === "Identifier" ? [d.id.name] : [])); + } + if (!("id" in declaration) || !declaration.id) return []; + return declaration.id.type === "Identifier" ? [declaration.id.name] : []; +} diff --git a/js/common/package.ts b/js/common/package.ts index 7ad9309b5b..3ada558788 100644 --- a/js/common/package.ts +++ b/js/common/package.ts @@ -6,6 +6,7 @@ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { publint } from "publint"; import { formatMessage } from "publint/utils"; +import { problems } from "./declarations.ts"; console.log("✍️ Rewriting package.json..."); const pkg = JSON.parse(readFileSync("package.json", "utf8")); @@ -120,6 +121,18 @@ if (messages.length > 0) { process.exit(1); } +console.log("🔍 Checking declaration imports..."); +const declarations = new Map(); +for (const rel of new Bun.Glob("**/*.d.ts").scanSync("dist")) { + const file = resolve("dist", rel); + declarations.set(file, readFileSync(file, "utf8")); +} +const unresolved = problems(resolve("dist"), declarations); +if (unresolved.length > 0) { + for (const problem of unresolved) console.error(problem); + process.exit(1); +} + console.log("📦 Package built successfully in dist/"); // Optionally emit a jsr.json so the package can also publish to JSR (jsr.io). diff --git a/js/hang/package.json b/js/hang/package.json index 51bcaf28d6..0c977d72ee 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -29,8 +29,8 @@ "@moq/loc": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "@svta/cml-iso-bmff": "^1.0.5", - "@svta/cml-utils": "1.6.0", + "@svta/cml-iso-bmff": "^1.0.6", + "@svta/cml-utils": "1.6.1", "@zod/mini": "^4.6.5", "zod": "^4.6.5" }, diff --git a/js/hang/src/catalog/binary.ts b/js/hang/src/catalog/binary.ts index 593eaf24d5..e62c2ee038 100644 --- a/js/hang/src/catalog/binary.ts +++ b/js/hang/src/catalog/binary.ts @@ -41,6 +41,16 @@ export const BinaryConfigSchema = z.looseObject({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this track's payloads reach the transport behind the broadcast's earliest rendition, + // with the same meaning and encoding as a video rendition's `delay`. Only measured for payloads + // that carry a capture time. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/consumer.test.ts b/js/hang/src/catalog/consumer.test.ts index 8fd1c8caf7..6b0e328aae 100644 --- a/js/hang/src/catalog/consumer.test.ts +++ b/js/hang/src/catalog/consumer.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import { TRACK } from "./format"; -import { checkRenditions, MAX_RENDITIONS, type Root, TooManyRenditions, watch } from "./root"; +import { checkRenditions, EscapingBroadcast, MAX_RENDITIONS, type Root, TooManyRenditions, watch } from "./root"; function catalog(count: number): Root { return { @@ -40,6 +40,56 @@ test("watch refuses an oversized catalog update", async () => { broadcast.close(); }); +// A catalog whose one audio rendition references `broadcast`. +function referencing(broadcast: string): Root { + const root = catalog(1); + const [config] = Object.values(root.audio?.renditions ?? {}); + if (config) config.broadcast = Moq.Path.normalizeRelative(broadcast); + return root; +} + +// Watch the catalog of `room/alice`, published through an origin and requested from it. +async function watchRequested(root: Root) { + const origin = new Moq.Origin.Producer(); + const broadcast = origin.createBroadcast(Moq.Path.from("room/alice")); + const track = broadcast.createTrack(TRACK); + const producer = new Json.Snapshot.Producer({ track, deltaRatio: 0 }); + broadcast.announce(); + const request = origin.request(Moq.Path.from("room/alice")); + const active = request.active.peek(); + if (!active) throw new Error("request did not resolve"); + const consumer = watch(active)[Symbol.asyncIterator](); + producer.update(root); + try { + return await consumer.next(); + } finally { + await consumer.return?.(); + request.close(); + broadcast.close(); + origin.close(); + } +} + +test("watch refuses a broadcast reference escaping the requested path", async () => { + await expect(watchRequested(referencing("../../../other"))).rejects.toBeInstanceOf(EscapingBroadcast); +}); + +test("watch accepts a sibling reference under the same root and yields it unresolved", async () => { + const root = referencing("./bob"); + expect(await watchRequested(root)).toMatchObject({ value: root }); +}); + +test("watch on a standalone broadcast refuses any parent reference", async () => { + const broadcast = new Moq.Broadcast.Producer(); + const track = broadcast.createTrack(TRACK); + const producer = new Json.Snapshot.Producer({ track, deltaRatio: 0 }); + const consumer = watch(broadcast.consume())[Symbol.asyncIterator](); + producer.update(referencing("../bob")); + await expect(consumer.next()).rejects.toBeInstanceOf(EscapingBroadcast); + producer.finish(); + broadcast.close(); +}); + test("watch subscribes and yields typed catalog updates", async () => { const broadcast = new Moq.Broadcast.Producer(); const track = broadcast.createTrack(TRACK); diff --git a/js/hang/src/catalog/json.ts b/js/hang/src/catalog/json.ts index c5900559d7..374b845d94 100644 --- a/js/hang/src/catalog/json.ts +++ b/js/hang/src/catalog/json.ts @@ -40,6 +40,16 @@ export const JsonConfigSchema = z.looseObject({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this track's payloads reach the transport behind the broadcast's earliest rendition, + // with the same meaning and encoding as a video rendition's `delay`. Only measured for payloads + // that carry a capture time. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index c37154805c..e6e7c64de2 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -113,7 +113,11 @@ test("delay parses beside jitter and zero is absent", () => { renditions: { video: { codec: "avc1.64001f", container: { kind: "legacy" }, jitter: 34, delay: 200 } }, }, text: { renditions: { captions: { format: "vtt", container: { kind: "legacy" }, delay: 120 } } }, + json: { tracks: { gps: { mode: "stream", jitter: 10, delay: 250 } } }, + binary: { tracks: { frames: { mode: "snapshot", delay: 0 } } }, }); + expect(parsed.json?.tracks.gps?.delay).toBe(u53(250)); + expect(parsed.binary?.tracks.frames?.delay).toBeUndefined(); expect(parsed.audio?.renditions.audio?.delay).toBeUndefined(); expect(parsed.video?.renditions.video?.delay).toBe(u53(200)); expect(parsed.text?.renditions.captions?.delay).toBe(u53(120)); diff --git a/js/hang/src/catalog/root.ts b/js/hang/src/catalog/root.ts index 5ea8453ded..97b1689600 100644 --- a/js/hang/src/catalog/root.ts +++ b/js/hang/src/catalog/root.ts @@ -1,5 +1,6 @@ import * as Json from "@moq/json"; import type * as Moq from "@moq/net"; +import { Path } from "@moq/net"; import * as z from "@zod/mini"; import { ArchiveSchema } from "./archive"; import { AudioSchema } from "./audio"; @@ -7,6 +8,7 @@ import { BinarySchema } from "./binary"; import { ClockSchema } from "./clock"; import { TRACK } from "./format"; import { JsonSchema } from "./json"; +import type { RelativeBroadcast } from "./path"; import { PRIORITY } from "./priority"; import { section } from "./section"; import { TextSchema } from "./text"; @@ -70,12 +72,49 @@ export function checkRenditions(root: Root): Root { return root; } -/** Subscribe to a broadcast's catalog and iterate validated root updates. */ +/** A catalog update carried a `broadcast` reference that walks above its reader's root. */ +export class EscapingBroadcast extends Error { + /** The offending reference, normalized. */ + readonly broadcast: RelativeBroadcast; + + constructor(broadcast: RelativeBroadcast) { + super(`catalog broadcast reference escapes the root: ${broadcast}`); + this.name = "EscapingBroadcast"; + this.broadcast = broadcast; + } +} + +// Refuse an update with a `broadcast` reference that walks above the root from `base`. +function checkResolvable(root: Root, base: Moq.Path.Valid): Root { + // Every section carrying a `broadcast` reference must be listed here; one left out + // silently exempts its tracks from the check. + const sections = [ + root.video?.renditions, + root.audio?.renditions, + root.text?.renditions, + root.json?.tracks, + root.binary?.tracks, + ]; + for (const section of sections) { + for (const { broadcast } of Object.values(section ?? {})) { + if (broadcast && Path.tryResolve(base, broadcast) === undefined) throw new EscapingBroadcast(broadcast); + } + } + return root; +} + +/** + * Subscribe to a broadcast's catalog and iterate validated root updates. + * + * Throws {@link TooManyRenditions} or {@link EscapingBroadcast} on an update that fails + * validation. `broadcast` references are checked against the handle's `path` and yielded + * unresolved. + */ export async function* watch(broadcast: Moq.Broadcast.Consumer): AsyncIterable { const track = broadcast.track(TRACK).subscribe({ priority: PRIORITY.catalog }); try { const consumer = new Json.Snapshot.Consumer({ track, schema: RootSchema }); - for await (const root of consumer) yield checkRenditions(root); + for await (const root of consumer) yield checkResolvable(checkRenditions(root), broadcast.path); } finally { track.close(); } diff --git a/js/json/src/snapshot/encoder.ts b/js/json/src/snapshot/encoder.ts index 67ce5047b1..82800aa939 100644 --- a/js/json/src/snapshot/encoder.ts +++ b/js/json/src/snapshot/encoder.ts @@ -42,6 +42,14 @@ export interface Config { // `"none"`/unset (the default) writes plaintext JSON frames. A {@link Decoder} reading them // must set the same {@link compression}. compression?: Compression; + + /** + * Bytes a group may hold before it rolls, defaulting to moq-net's per-group cache limit. Lets a + * test reach the limit without megabytes of JSON. + * + * @internal + */ + maxGroupBytes?: number; } /** One encoded frame, and the group boundary it implies. */ @@ -106,6 +114,7 @@ export interface Pending extends Encoded { export class Encoder { #config: Config; #compress: boolean; + #maxGroupBytes: number; // The last encoded value, normalized through JSON so it matches what landed on the wire. The // baseline every delta is diffed against, and `undefined` until the first snapshot. @@ -140,6 +149,7 @@ export class Encoder { constructor(config: Config = {}) { this.#config = config; this.#compress = isDeflate(config.compression); + this.#maxGroupBytes = config.maxGroupBytes ?? Group.MAX_GROUP_CACHE_BYTES; } /** @@ -218,7 +228,7 @@ export class Encoder { // can come out slightly larger than its input, so the plaintext is not an upper bound. // Compressing first advances the window, but `#snapshot` opens a fresh one, so an // over-budget delta costs only the wasted compression. - if (this.#snapshotLen + this.#deltaBytes + payload.length <= Group.MAX_GROUP_CACHE_BYTES) { + if (this.#snapshotLen + this.#deltaBytes + payload.length <= this.#maxGroupBytes) { this.#last = json; this.#deltaBytes += payload.length; this.#groupFrames += 1; diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index ca9d252be3..e5b35cb978 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -51,18 +51,22 @@ export class Producer { group.close(); } - /** Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. */ - update(value: T): void { + /** + * Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. + * + * `at` is when the value was captured, written as its frame timestamp. Defaults to now. + */ + update(value: T, at: Time.Timestamp = Time.Timestamp.now()): void { const frame = this.#encoder.update(value); if (!frame) return; // A throw here leaves the frame uncommitted, so the next update resynchronizes with a fresh // snapshot. A delta against a snapshot no consumer ever saw would be unreadable. - this.#write(frame); + this.#write(frame, at); frame.commit(); } - #write(encoded: Encoded): void { + #write(encoded: Encoded, at: Time.Timestamp): void { // Check before touching a group. A keyframe closes the previous group and publishes its // replacement before the frame is written, so discovering the limit inside `writeFrame` would // leave an empty newest group behind: a snapshot consumer jumps to the newest, so the last @@ -77,7 +81,7 @@ export class Producer { const group = this.#track.appendGroup(); try { - group.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() }); + group.writeFrame({ payload: encoded.payload, timestamp: at }); } catch (err) { // The group carries no frames, so close it rather than leaving it open on the track. A // rejected frame doesn't close the track, and a consumer that already advanced into this @@ -98,7 +102,7 @@ export class Producer { } if (!this.#group) throw new Error("delta with no open group"); - this.#group.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() }); + this.#group.writeFrame({ payload: encoded.payload, timestamp: at }); } /** diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index 4200fd9654..668ddf7fac 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { Group, Error as NetError, StreamCode, Time, Track } from "@moq/net"; import { Consumer } from "./consumer.ts"; +import { Encoder } from "./encoder.ts"; import { Producer } from "./producer.ts"; type Value = Record; @@ -358,38 +359,38 @@ test("a delta that would overflow the snapshot rolls a new one instead", async ( test("a compressed delta is gated on its encoded size, not its plaintext", async () => { // A sync-flushed DEFLATE frame can come out larger than its input, so the plaintext is not an - // upper bound on what lands in the group. A snapshot that fills the cache to within a few bytes - // plus a tiny patch that compresses to more than it measures would otherwise slip through the - // gate and evict frame 0. + // upper bound on what lands in the group. A patch that fits the budget by its plaintext but not + // by its encoded size would otherwise slip through the gate and overflow the group. + const value = { v: "x".repeat(1000) }; + const patched = { ...value, q: "a" }; + const plaintext = JSON.stringify({ q: "a" }).length; + + // Measure the frames with the default budget, which admits the delta. + const probe = new Encoder({ compression: "deflate" }); + const snapshot = probe.update(value); + snapshot?.commit(); + const delta = probe.update(patched); + expect(delta?.keyframe).toBe(false); + expect(delta?.payload.length).toBeGreaterThan(plaintext); + + // A budget with room for the plaintext patch but not the encoded one. + const maxGroupBytes = (snapshot?.payload.length ?? 0) + plaintext; const track = new Track.Producer("test"); - const producer = new Producer({ track, compression: "deflate" }); - - // Highly repetitive, so the compressed snapshot lands just under the cap. - producer.update({ v: "x".repeat(Group.MAX_GROUP_CACHE_BYTES) }); - producer.update({ v: "x".repeat(Group.MAX_GROUP_CACHE_BYTES), q: "a" }); + const producer = new Producer({ track, compression: "deflate", maxGroupBytes }); + producer.update(value); + producer.update(patched); producer.finish(); - // Whatever the split, no group may exceed the cache, and the newest value must be readable. - const subscriber = track.subscribe({ maxAge: REPLAY_LATENCY }).ordered(); - for (;;) { - const group = await subscriber.nextGroup(); - if (!group) break; - let bytes = 0; - for (;;) { - const frame = await group.readFrame(); - if (!frame) break; - bytes += frame.payload.byteLength; - } - expect(bytes).toBeLessThanOrEqual(Group.MAX_GROUP_CACHE_BYTES); - } + // The patch rolled into a fresh snapshot rather than joining the first group. + expect(await structure(track.subscribe({ maxAge: REPLAY_LATENCY }).ordered())).toEqual([1, 1]); const consumer = new Consumer({ track: track.subscribe({ maxAge: REPLAY_LATENCY }), compression: "deflate", }); const values: Value[] = []; - for await (const value of consumer) values.push(value); - expect(values[values.length - 1]).toEqual({ v: "x".repeat(Group.MAX_GROUP_CACHE_BYTES), q: "a" }); + for await (const out of consumer) values.push(out); + expect(values[values.length - 1]).toEqual(patched); }); // A malformed or failed group must reach the caller; only an explicit retention gap is resumable. @@ -402,3 +403,15 @@ test("snapshot consumer propagates a non-gap frame failure", async () => { group.close(new NetError.Stream(StreamCode.Internal)); await expect(consumer.next()).rejects.toMatchObject({ code: StreamCode.Internal }); }); + +test("a capture timestamp is written on snapshots and deltas alike", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track, deltaRatio: 100 }); + producer.update({ a: 1, b: "x".repeat(64) }, Time.Timestamp.fromMillis(1_000)); + producer.update({ a: 2, b: "x".repeat(64) }, Time.Timestamp.fromMillis(2_000)); + producer.finish(); + + const group = await track.subscribe().ordered().nextGroup(); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(1_000); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(2_000); +}); diff --git a/js/json/src/stream/producer.ts b/js/json/src/stream/producer.ts index 369aa9f161..346c50cb0e 100644 --- a/js/json/src/stream/producer.ts +++ b/js/json/src/stream/producer.ts @@ -29,8 +29,10 @@ export class Producer { * this mode promises, so the failure is surfaced rather than papered over with a second group. * The track is aborted rather than closed cleanly, so a consumer sees the failure instead of a * log that merely looks complete. + * + * `at` is when the value was captured, written as its frame timestamp. Defaults to now. */ - append(value: T): void { + append(value: T, at: Time.Timestamp = Time.Timestamp.now()): void { // Encode first, so a value that can't be serialized doesn't publish an empty group that // subscribers would advance into and wait on. Opening the group afterwards is safe because // the record stays uncommitted until the write lands. @@ -58,7 +60,7 @@ export class Producer { } try { - this.#group.writeFrame({ payload: record.payload, timestamp: Time.Timestamp.now() }); + this.#group.writeFrame({ payload: record.payload, timestamp: at }); } catch (err) { // The group is live, so the record is a hole in the log and a second group would hand // consumers that gap dressed up as a complete log. diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 1e342998c2..92fa0fe2b2 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -149,3 +149,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => subscriber.close(); producer.finish(); }); + +test("each record keeps its capture timestamp", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + producer.append(1, Time.Timestamp.fromMillis(1_000)); + producer.append(2, Time.Timestamp.fromMillis(2_000)); + producer.finish(); + + const group = await track.subscribe().ordered().nextGroup(); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(1_000); + expect((await group?.readFrame())?.timestamp.as(Time.Timescale.MILLI)).toBe(2_000); +}); diff --git a/js/justfile b/js/justfile index bcad24af59..4be426446e 100644 --- a/js/justfile +++ b/js/justfile @@ -84,7 +84,7 @@ test $FILES="": exit 0 fi bun install --frozen-lockfile - bun test common/deps.test.ts common/workers.test.ts + bun test common/declarations.test.ts common/deps.test.ts common/workers.test.ts if tty -s; then bun run --filter='*' --elide-lines=0 test else diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts new file mode 100644 index 0000000000..e0f294ab61 --- /dev/null +++ b/js/net/bench/forward.ts @@ -0,0 +1,91 @@ +/** Sweep interest heads and received routes for one route re-price forwarded from a session. */ +import * as Announce from "../src/announced.ts"; +import { Producer as BroadcastProducer } from "../src/broadcast.ts"; +import type { Established } from "../src/connection/established.ts"; +import { forwardAnnounced } from "../src/connection/forward.ts"; +import { Route } from "../src/hop.ts"; +import { Producer } from "../src/origin.ts"; +import * as Path from "../src/path.ts"; +import { registerWire } from "../src/wire.ts"; + +const headCounts = [1, 4, 16]; +const routeCounts = [8, 32, 128]; +const updates = 32; +let checksum = 0; + +// Let every interest stream's forwarding loop drain its queue. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** A session standing in for the wire: one announce stream per interest head, driven by the bench. */ +class FakeSession { + readonly discovery = true; + readonly streams: Announce.Producer[] = []; + readonly closed: Promise; + die!: () => void; + + constructor() { + registerWire(this, { consume: () => new BroadcastProducer().consume() }); + this.closed = new Promise((resolve) => { + this.die = () => resolve(null); + }); + } + + announced(): Announce.Consumer { + const stream = new Announce.Producer(); + this.streams.push(stream); + return stream.consume(); + } +} + +const announce = (stream: Announce.Producer, prefix: Path.Valid, cost: bigint, kind: "start" | "update") => + stream.append({ prefix, captures: undefined, kind, route: Route.normalize({ cost }) }); + +console.log("touched,heads,routes,update_us"); +for (const touched of ["covering", "single"] as const) { + for (const headCount of headCounts) { + for (const routeCount of routeCounts) { + const origin = new Producer(); + const heads = Array.from({ length: headCount }, (_, index) => Path.from(`h${index}`)); + const scoped = origin.scope( + Path.empty(), + new Path.Patterns(heads.map((head) => Path.Pattern.subtree(head))), + ); + const session = new FakeSession(); + forwardAnnounced(session as unknown as Established, scoped); + if (session.streams.length !== headCount) throw new Error("expected one announce stream per head"); + + // Routes beneath the heads, spread across them, plus one above every head. The wire + // presents that covering route on each head's stream, so it lands once per head. + for (let index = 0; index < routeCount; index++) { + const head = index % headCount; + announce(session.streams[head], Path.join(heads[head], Path.from(`r${index}`)), 1n, "start"); + } + for (const stream of session.streams) announce(stream, Path.empty(), 1n, "start"); + await flush(); + + const path = touched === "covering" ? Path.empty() : Path.join(heads[0], Path.from("r0")); + const table = origin.broadcasts(); + if (table.peek().size !== routeCount + 1) throw new Error(`expected ${routeCount + 1} routes`); + + const start = performance.now(); + for (let index = 0; index < updates; index++) { + const cost = BigInt(index + 2); + if (touched === "covering") { + for (const stream of session.streams) announce(stream, path, cost, "update"); + } else { + announce(session.streams[0], path, cost, "update"); + } + await flush(); + if (table.peek().get(path)?.cost.warm !== cost) throw new Error("re-price did not land"); + checksum += table.peek().size; + } + const elapsed = performance.now() - start; + console.log(`${touched},${headCount},${routeCount},${((elapsed * 1000) / updates).toFixed(1)}`); + + session.die(); + await flush(); + origin.close(); + } + } +} +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/bench/frames.ts b/js/net/bench/frames.ts new file mode 100644 index 0000000000..34752d5262 --- /dev/null +++ b/js/net/bench/frames.ts @@ -0,0 +1,212 @@ +/** Sweep frame and chunk size for a group stream decoded into a group and drained by a reader. */ +import type { Consumer as GroupConsumer } from "../src/group.ts"; +import { Producer } from "../src/group.ts"; +import { NativeSession } from "../src/ietf/adapter.ts"; +import { Group as IetfGroup } from "../src/ietf/object.ts"; +import { Subscribe, SubscribeOk } from "../src/ietf/subscribe.ts"; +import { Subscriber as IetfSubscriber } from "../src/ietf/subscriber.ts"; +import { ALPN, Version as IetfVersion } from "../src/ietf/version.ts"; +import { readFrames } from "../src/lite/group.ts"; +import { createMockTransportPair } from "../src/mock.ts"; +import * as Path from "../src/path.ts"; +import { Reader, Stream } from "../src/stream.ts"; +import * as Varint from "../src/varint.ts"; + +const frameSizes = [16, 100, 1000]; +const chunkSizes = [1200, 16 * 1024, 64 * 1024]; +const framesPerGroup = 50; +// A backlog of tiny frames in one large chunk: the worst case for how long batching holds the first. +const burst = { frameSize: 16, chunkSize: 64 * 1024, frames: 3000 }; +// A chunk carrying many frames must cost less per frame than one carrying a single frame, or +// decoding has gone back to paying a wakeup per frame. Loose, since the fixed per-chunk cost is +// what the batched case amortizes and the machine is noisy. +const maxBatchedRatio = 0.8; +let checksum = 0; + +/** Turns one group stream into a readable group, resolving `done` once the stream is handled. */ +type Open = (stream: ReadableStream) => { group: Promise; done: Promise }; + +/** A wire format's group stream: how a frame is encoded, and where its groups are read. */ +interface Protocol { + name: string; + // Decode about this many frames per row so each takes similar time. + frames: number; + encode(frameSize: number, index: number): Uint8Array[]; + // A fresh subscription per row, so one row's retained groups don't slow the next. + subscribe(): Promise<{ open: Open; close: () => void }>; +} + +// A lite-05+ group: a zigzag timestamp delta, a size, and the payload. +const lite: Protocol = { + name: "lite", + frames: 200_000, + encode: (frameSize, index) => [ + Varint.encode(2 * 33_333), + Varint.encode(frameSize), + new Uint8Array(frameSize).fill(index), + ], + async subscribe() { + const open: Open = (stream) => { + const producer = new Producer(0); + const done = readFrames(new Reader(stream), producer, 1_000_000).then(() => producer.close()); + return { group: Promise.resolve(producer.consume()), done }; + }; + return { open, close: () => {} }; + }, +}; + +// A moq-transport subgroup stream with no properties, through the subscriber that routes it to its +// track. Every retained group costs each later one a timeline scan (#4246), so a row stays at a +// few hundred groups. +const IETF_VERSION = IetfVersion.DRAFT_19; +const IETF_ALIAS = 9n; +const IETF_FLAGS = { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, +}; + +const ietf: Protocol = { + name: "ietf", + frames: 20_000, + encode: (frameSize, index) => [ + Varint.encodeLeadingOnes(0), + Varint.encodeLeadingOnes(frameSize), + new Uint8Array(frameSize).fill(index), + ], + async subscribe() { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const subscriber = new IetfSubscriber({ session: new NativeSession(pair.server, IETF_VERSION, true) }); + const track = subscriber.consume(Path.from("bench")).track("video").subscribe(); + + const peer = await Stream.accept(pair.client, IETF_VERSION); + if (!peer) throw new Error("the subscriber never subscribed"); + await peer.reader.u53(); + const request = await Subscribe.decode(peer.reader, IETF_VERSION); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ requestId: request.requestId, trackAlias: IETF_ALIAS }).encode( + peer.writer, + IETF_VERSION, + ); + + const ordered = track.ordered(); + let groupId = 0; + const open: Open = (stream) => { + const header = new IetfGroup({ + trackAlias: IETF_ALIAS, + groupId: groupId++, + subGroupId: 0, + publisherPriority: 0, + flags: IETF_FLAGS, + }); + const done = subscriber.handleGroup(header, new Reader(stream, undefined, IETF_VERSION)); + return { group: ordered.nextGroup(), done }; + }; + return { open, close: () => track.close() }; + }, +}; + +/** `count` frames of `frameSize` bytes, split into chunks. */ +function encode(protocol: Protocol, frameSize: number, chunkSize: number, count: number): Uint8Array[] { + const parts: Uint8Array[] = []; + for (let index = 0; index < count; index++) parts.push(...protocol.encode(frameSize, index)); + const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + + const chunks: Uint8Array[] = []; + for (let start = 0; start < bytes.byteLength; start += chunkSize) { + chunks.push(bytes.subarray(start, Math.min(start + chunkSize, bytes.byteLength))); + } + return chunks; +} + +/** Decode one group, returning nanoseconds until the reader has every frame and until it had the first. */ +async function run(open: Open, chunks: Uint8Array[], count: number): Promise<{ total: number; first: number }> { + // Queue every chunk up front so only the decode and the handoff are timed, not the source. + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + + const start = performance.now(); + const { group, done } = open(stream); + const consumer = await group; + if (!consumer) throw new Error("no group"); + + let first = 0; + let read = 0; + for (;;) { + const frame = await consumer.readFrame(); + if (!frame) break; + if (read++ === 0) first = performance.now() - start; + checksum += frame.payload[0]; + } + await done; + const total = performance.now() - start; + if (read !== count) throw new Error(`read ${read} of ${count} frames`); + return { total: total * 1e6, first: first * 1e6 }; +} + +/** Decode the protocol's frames in groups of `count`, returning ns per frame and the mean µs to the first frame. */ +async function measure( + protocol: Protocol, + chunks: Uint8Array[], + count: number, +): Promise<{ ns: number; first: number }> { + const groups = Math.max(1, Math.floor(protocol.frames / count)); + const { open, close } = await protocol.subscribe(); + + // Warm up so the first row doesn't pay for the JIT. + for (let index = 0; index < 20; index++) await run(open, chunks, count); + + let elapsed = 0; + let first = 0; + for (let index = 0; index < groups; index++) { + const result = await run(open, chunks, count); + elapsed += result.total; + first += result.first; + } + close(); + return { ns: elapsed / (groups * count), first: first / groups / 1000 }; +} + +console.log("protocol,frame_bytes,chunk_bytes,frames_per_chunk,ns_per_frame,first_frame_us"); + +for (const protocol of [lite, ietf]) { + const row = (frameSize: number, chunkSize: number, perChunk: number, result: { ns: number; first: number }) => + console.log( + `${protocol.name},${frameSize},${chunkSize},${perChunk.toFixed(1)},${result.ns.toFixed(0)},${result.first.toFixed(2)}`, + ); + + for (const frameSize of frameSizes) { + let single: number | undefined; + for (const chunkSize of [frameSize + 8, ...chunkSizes]) { + const chunks = encode(protocol, frameSize, chunkSize, framesPerGroup); + const perChunk = framesPerGroup / chunks.length; + const result = await measure(protocol, chunks, framesPerGroup); + row(frameSize, chunkSize, perChunk, result); + + if (single === undefined) { + single = result.ns; + } else if (perChunk >= 10 && result.ns > single * maxBatchedRatio) { + throw new Error( + `${protocol.name}: ${frameSize} byte frames at ${perChunk.toFixed(1)} per chunk cost ${result.ns.toFixed(0)} ns/frame, over ${maxBatchedRatio}x the ${single.toFixed(0)} ns of one per chunk`, + ); + } + } + } + + const chunks = encode(protocol, burst.frameSize, burst.chunkSize, burst.frames); + row(burst.frameSize, burst.chunkSize, burst.frames / chunks.length, await measure(protocol, chunks, burst.frames)); +} + +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/bench/reader.ts b/js/net/bench/reader.ts new file mode 100644 index 0000000000..76eac2e4c9 --- /dev/null +++ b/js/net/bench/reader.ts @@ -0,0 +1,56 @@ +/** Sweep frame size and chunk size for one Reader.read of a fragmented frame. */ +import { Reader } from "../src/stream.ts"; + +const frameSizes = [16 * 1024, 256 * 1024, 1024 * 1024]; +const chunkSizes = [1200, 16 * 1024, 1024 * 1024]; +const bytes = 16 * 1024 * 1024; // Read about this many bytes per case so each row takes similar time. +// Per-byte cost must not grow with frame size, or reassembly has gone quadratic. Each chunk size +// compares against its smallest frame that spans several chunks, since a single chunk skips the +// copy entirely. The margin is loose because that frame pays the fixed per-read overhead over the +// fewest bytes. +const maxSlope = 4; +let checksum = 0; + +console.log("frame_bytes,chunk_bytes,chunks,ns_per_byte"); +const smallest = new Map(); +for (const frameSize of frameSizes) { + for (const chunkSize of chunkSizes) { + const frame = new Uint8Array(frameSize).fill(1); + const chunks: Uint8Array[] = []; + for (let offset = 0; offset < frameSize; offset += chunkSize) { + chunks.push(frame.subarray(offset, Math.min(offset + chunkSize, frameSize))); + } + + const iterations = Math.max(1, Math.floor(bytes / frameSize)); + let elapsed = 0; + for (let index = 0; index < iterations; index++) { + // Queue the whole frame up front so only the Reader is timed, not the source. + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + const reader = new Reader(stream); + + const start = performance.now(); + const read = await reader.read(frameSize); + elapsed += performance.now() - start; + + checksum += read[read.byteLength - 1]; + } + + const ns = (elapsed * 1e6) / (iterations * frameSize); + console.log(`${frameSize},${chunkSize},${chunks.length},${ns.toFixed(3)}`); + + const baseline = smallest.get(chunkSize); + if (baseline === undefined) { + if (chunks.length > 1) smallest.set(chunkSize, ns); + } else if (ns > baseline * maxSlope) { + throw new Error( + `${frameSize} byte frames cost ${ns.toFixed(3)} ns/byte, over ${maxSlope}x ${baseline.toFixed(3)}`, + ); + } + } +} +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts new file mode 100644 index 0000000000..a9d910c8b6 --- /dev/null +++ b/js/net/bench/track.ts @@ -0,0 +1,108 @@ +/** Sweep retained groups and subscribers for one group published through a track and received. */ +import { type Consumer as GroupConsumer, Producer as GroupProducer } from "../src/group.ts"; +import { Milli, Timestamp } from "../src/time.ts"; +import { Producer, type Subscriber } from "../src/track.ts"; + +const retainedCounts = [25, 100, 400, 1500]; +const subscriberCounts = [1, 4, 16]; +// Open groups each subscriber is still reading, so their latency guards re-evaluate on every arrival. +const heldCounts = [0, 16]; +const groups = 100; +const reps = 7; +// Publishing a group must not cost more as the retained window grows, or a guard or cache pass +// has gone back to scanning every retained group. The sweep spans 60x, so a scan lands well past +// this; the margin is loose because the machine is noisy. +const maxSlope = 5; +const payload = new Uint8Array(80); +let checksum = 0; + +// A clock that advances a millisecond per group, so a window of N ms retains N groups and every +// publish ages the oldest one out. Timing reads the real clock. +const now = performance.now.bind(performance); +let clock = 0; +performance.now = () => clock; + +// Let every woken reader re-evaluate its guard before the next group. +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function receive(subscribers: Subscriber[]): GroupConsumer[] { + const received: GroupConsumer[] = []; + for (const subscriber of subscribers) { + const group = subscriber.tryRecvGroup(); + if (!group) throw new Error("group was not delivered"); + const frame = group.tryReadFrame(); + if (!frame) throw new Error("frame was not delivered"); + checksum += frame.payload.byteLength; + received.push(group); + } + return received; +} + +async function measure(retained: number, subscriberCount: number, held: number): Promise { + // Held groups would age out too, so those rows keep everything instead. + const window = Milli(held > 0 ? 3_600_000 : retained); + const producer = new Producer("bench").accept({ maxAge: window }); + const subscribers = Array.from({ length: subscriberCount }, () => producer.subscribe({ maxAge: window })); + let sequence = 0; + // Inserted by sequence, the way the wire hands a subscribed track its groups. + const publish = (close: boolean) => { + clock++; + const group = new GroupProducer(sequence); + producer.writeGroup(group); + group.writeFrame({ payload, timestamp: Timestamp.fromMillis(sequence++) }); + if (close) group.close(); + return group; + }; + + // The held groups stay open, each with a reader parked on its next frame. + const open: GroupProducer[] = []; + const reads: Promise[] = []; + for (let index = 0; index < retained; index++) { + const isHeld = index < held; + const group = publish(!isHeld); + const received = receive(subscribers); + if (isHeld) { + open.push(group); + for (const consumer of received) reads.push(consumer.readFrame()); + } + } + await flush(); + + const start = now(); + for (let index = 0; index < groups; index++) { + publish(true); + receive(subscribers); + await flush(); + } + const elapsed = now() - start; + + for (const group of open) group.close(); + await Promise.all(reads); + producer.close(); + return (elapsed * 1000) / groups; +} + +// Warm the JIT so the first row isn't the slowest. +await measure(100, 1, 0); + +console.log("retained,subscribers,held,publish_us"); +for (const held of heldCounts) { + for (const subscriberCount of subscriberCounts) { + let baseline: number | undefined; + for (const retained of retainedCounts) { + const samples: number[] = []; + for (let rep = 0; rep < reps; rep++) samples.push(await measure(retained, subscriberCount, held)); + // The fastest run is the one least disturbed by the rest of the machine. + const us = Math.min(...samples); + console.log(`${retained},${subscriberCount},${held},${us.toFixed(1)}`); + + baseline ??= us; + if (us > baseline * maxSlope) { + throw new Error( + `${retained} retained groups cost ${us.toFixed(1)} us/group, over ${maxSlope}x ${baseline.toFixed(1)}`, + ); + } + } + } +} +if (checksum === 0) throw new Error("benchmark did no work"); diff --git a/js/net/package.json b/js/net/package.json index ce53b03637..7ec2b125f1 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -33,7 +33,7 @@ }, "devDependencies": { "@types/bun": "^1.4.2", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "@typescript/lib-dom": "npm:@types/web@^0.0.350", "rimraf": "^6.1.3", "typescript": "7.0.2", diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 1a0c019c42..28d202c48c 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -109,6 +109,32 @@ test("concurrent dynamic producers share a sequence namespace", async () => { broadcast.close(); }); +test("a sibling producer's groups do not settle an aborted end", async () => { + const broadcast = new BroadcastProducer(); + const firstSubscriber = broadcast.track("media").subscribe(); + const secondSubscriber = broadcast.track("media").subscribe(); + const firstRequest = await wireOf(broadcast).requested(); + const secondRequest = await wireOf(broadcast).requested(); + if (!firstRequest || !secondRequest) throw new Error("expected requests"); + const firstProducer = firstRequest.accept(); + const secondProducer = secondRequest.accept(); + + const reader = firstProducer.subscribe(); + firstProducer.finishAt(3); + for (let i = 0; i < 3; i++) secondProducer.appendGroup().close(); + const boom = new Error("boom"); + firstProducer.close(boom); + + // The first producer never received the groups its end promised, so it was cut off. + expect(firstProducer.closed.peek()).toBe(boom); + await expect(reader.recvGroup()).rejects.toBe(boom); + + firstSubscriber.close(); + secondSubscriber.close(); + secondProducer.close(); + broadcast.close(); +}); + test("closing a broadcast rejects a dequeued request", async () => { const broadcast = new BroadcastProducer(); const subscriber = broadcast.track("media").subscribe(); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 99ce972577..c8f0b5296a 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -8,6 +8,7 @@ import { NotFound } from "./error.ts"; import type { Consumer as GroupConsumer } from "./group.ts"; import { Route } from "./hop.ts"; import { hooks, type TrackSequence } from "./internal.ts"; +import * as Path from "./path.ts"; import * as track from "./track.ts"; import { registerWire, trackOf, type Broadcast as Wire } from "./wire.ts"; @@ -20,6 +21,7 @@ export interface Announcer { } let attachAnnouncer: (producer: Producer, announcer: Announcer) => void; +let stampProducer: (producer: Producer, path: Path.Valid) => void; /** Reactive backing state shared by broadcast producers and consumers. */ class BroadcastState { @@ -158,6 +160,7 @@ async function fetchGroup( export class Producer { #state = new BroadcastState(); #announcer?: Announcer; + #path = Path.empty(); constructor() { registerWire(this, this.#wire(false)); @@ -168,6 +171,9 @@ export class Producer { producer.#announcer = announcer; }; hooks.attachAnnouncer = attachAnnouncer; + stampProducer = (producer, path) => { + producer.#path = path; + }; } /** @@ -178,9 +184,9 @@ export class Producer { return this.#state.closed; } - /** A read handle for this broadcast. */ + /** A read handle for this broadcast, named by the path the origin created it at. */ consume(): Consumer { - return makeConsumer(this.#state); + return makeConsumer({ state: this.#state, path: this.#path }); } async #requested(): Promise { @@ -272,9 +278,15 @@ export class Producer { } } +// What a new consumer handle inherits: the shared broadcast plus the path naming it. +interface Shared { + state: BroadcastState; + path: Path.Valid; +} + // Constructs a Consumer from within this module without exposing a public constructor // that would leak the unexported BroadcastState. Assigned in the class's static block. -let makeConsumer: (state: BroadcastState) => Consumer; +let makeConsumer: (shared: Shared) => Consumer; /** * The read side of a broadcast. @@ -286,13 +298,15 @@ let makeConsumer: (state: BroadcastState) => Consumer; */ export class Consumer { #state: BroadcastState; + #path: Path.Valid; // Guards against a double close() on this handle over-decrementing the consumer count. #closed = false; - protected constructor(state?: never); - protected constructor(state?: BroadcastState) { - this.#state = state ?? new BroadcastState(); + protected constructor(shared?: never); + protected constructor(shared?: Shared) { + this.#state = shared?.state ?? new BroadcastState(); + this.#path = shared?.path ?? Path.empty(); this.#state.consumers++; registerWire(this, { subscribe: (name, options) => subscribe(this.#state, name, options, true), @@ -303,7 +317,23 @@ export class Consumer { } static { - makeConsumer = (state) => new Consumer(state as never); + makeConsumer = (shared) => new Consumer(shared as never); + hooks.stampPath = (target, path) => { + if (target instanceof Consumer) target.#path = path; + else stampProducer(target, path); + }; + } + + /** + * The path this handle names the broadcast by, which relative references in its catalog + * (hang's `broadcast` field) resolve against. + * + * An origin stamps each handle it hands out with the path it was requested at, relative to + * that origin handle's scope root, and a broadcast it created with the path it was created at. + * Empty for a standalone broadcast, which is then its own root: any `..` reference escapes. + */ + get path(): Path.Valid { + return this.#path; } /** @@ -320,8 +350,8 @@ export class Consumer { /** * Return another handle to the same broadcast, reference-counted with this one. * - * Both handles read the same tracks and share one {@link closed} state; the broadcast - * closes only once *every* handle has {@link close}d. Used by the connection's per-path + * Both handles read the same tracks, carry the same {@link path}, and share one {@link closed} + * state; the broadcast closes only once *every* handle has {@link close}d. Used by the connection's per-path * consume cache to share one subscription across callers. Subclasses that resolve info over * the wire override this to preserve their type (see the wire layer's consumed broadcast). */ @@ -329,10 +359,10 @@ export class Consumer { return new Consumer(this.shareState()); } - // Hand this consumer's backing state to a clone. Opaque (`never`) so the state type stays - // unexported; a subclass passes it straight back into its own `super(...)`. + // Hand this consumer's backing state and path to a clone. Opaque (`never`) so the state type + // stays unexported; a subclass passes it straight back into its own `super(...)`. protected shareState(): never { - return this.#state as never; + return { state: this.#state, path: this.#path } satisfies Shared as never; } /** Get a lazy handle for a track on this broadcast. Repeat subscriptions dedupe onto one upstream subscription. */ diff --git a/js/net/src/declarations.test.ts b/js/net/src/declarations.test.ts deleted file mode 100644 index 65636b4610..0000000000 --- a/js/net/src/declarations.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; - -const pkg = resolve(import.meta.dir, ".."); -const tsc = join(dirname(Bun.resolveSync("typescript/package.json", pkg)), "lib/tsc.js"); - -// `stripInternal` drops the export from the target `.d.ts` but leaves the import in any file -// that names the type, so a published consumer sees a module that does not export it. -test("emitted declarations import only names the target file exports", () => { - const outDir = mkdtempSync(join(tmpdir(), "moq-net-dts-")); - try { - const result = Bun.spawnSync( - ["bun", tsc, "-p", "tsconfig.build.json", "--emitDeclarationOnly", "--outDir", outDir], - { cwd: pkg, stdout: "pipe", stderr: "pipe" }, - ); - expect(result.exitCode, result.stderr.toString() || result.stdout.toString()).toBe(0); - - const files = dtsFiles(outDir); - expect(files.length).toBeGreaterThan(0); - - const parsed = new Map(); - for (const file of files) { - parsed.set(file, fileExports(readFileSync(file, "utf8"))); - } - - const problems: string[] = []; - for (const [file, info] of parsed) { - for (const { specifier, names } of info.imports) { - const target = dtsPath(file, specifier); - if (!target) continue; - - if (!parsed.has(target)) { - problems.push(`${relative(outDir, file)}: imports ${specifier}, which emitted no declarations`); - continue; - } - - for (const name of names) { - if (!exported(parsed, target, name)) { - problems.push( - `${relative(outDir, file)}: imports ${name} from ${specifier}, which does not export it`, - ); - } - } - } - } - - expect(problems).toEqual([]); - } finally { - rmSync(outDir, { recursive: true, force: true }); - } -}); - -type FileExports = { - names: Set; - stars: string[]; - imports: Array<{ specifier: string; names: string[] }>; -}; - -function dtsFiles(dir: string): string[] { - const found: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) found.push(...dtsFiles(path)); - else if (entry.name.endsWith(".d.ts")) found.push(path); - } - return found; -} - -function dtsPath(from: string, specifier: string): string | undefined { - if (!specifier.startsWith(".")) return undefined; - return `${resolve(dirname(from), specifier).replace(/\.(js|ts)$/, "")}.d.ts`; -} - -function exported(files: Map, file: string, name: string, seen = new Set()): boolean { - if (seen.has(file)) return false; - seen.add(file); - - const info = files.get(file); - if (!info) return false; - if (info.names.has(name)) return true; - - for (const specifier of info.stars) { - const target = dtsPath(file, specifier); - if (target && exported(files, target, name, seen)) return true; - } - return false; -} - -function fileExports(source: string): FileExports { - const names = new Set(); - const stars: string[] = []; - const imports: Array<{ specifier: string; names: string[] }> = []; - - const named = /^(import|export)(?:\s+type)?\s*\{([\s\S]*?)\}\s*(?:from\s+["']([^"']+)["'])?/gm; - const star = /^export\s+\*\s+from\s+["']([^"']+)["']/gm; - const asStar = /^export\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["']/gm; - const decl = - /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:type|interface|class|function|const|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm; - - for (const match of source.matchAll(named)) { - const [, kind, list, specifier] = match; - if (specifier) imports.push({ specifier, names: ident(list ?? "", "imported") }); - if (kind === "export") { - for (const name of ident(list ?? "", "exported")) names.add(name); - } - } - - for (const match of source.matchAll(star)) { - if (match[1]) stars.push(match[1]); - } - - for (const match of source.matchAll(asStar)) { - if (match[1]) names.add(match[1]); - } - - for (const match of source.matchAll(decl)) { - if (match[1]) names.add(match[1]); - } - - return { names, stars, imports }; -} - -function ident(list: string, side: "imported" | "exported"): string[] { - return list - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - .map((part) => { - const [left, right] = part.replace(/^type\s+/, "").split(/\s+as\s+/); - const name = side === "imported" ? left : (right ?? left); - return name?.trim() ?? ""; - }) - .filter(Boolean); -} diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 575f304755..f1c47f082c 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -418,11 +418,13 @@ export class Consumer { return true; } - #guard(operation: Promise): Promise { - if (this.#expire(true)) return Promise.reject(this.#terminal); + // Takes a thunk rather than a started promise, so a group already past its budget + // never begins the write: an abandoned write would reject later with no handler. + #guard(operation: () => Promise): Promise { + this.#expire(true); if (this.#terminal) return Promise.reject(this.#terminal); const expiry = this.#expiry; - if (!expiry) return operation; + if (!expiry) return operation(); return new Promise((resolve, reject) => { let settled = false; @@ -442,7 +444,7 @@ export class Consumer { }; for (const changed of expiry.changed) disposes.push(changed.subscribe(check)); - operation.then( + operation().then( (value) => finish(() => resolve(value)), (error: unknown) => finish(() => reject(error)), ); diff --git a/js/net/src/ietf/ietf.test.ts b/js/net/src/ietf/ietf.test.ts index 1f2e04f37d..ec6d906c20 100644 --- a/js/net/src/ietf/ietf.test.ts +++ b/js/net/src/ietf/ietf.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import * as Path from "../path.ts"; -import { Reader, Writer } from "../stream.ts"; +import { type Cursor, Reader, Writer } from "../stream.ts"; import { Timescale, Timestamp } from "../time.ts"; import * as Varint from "../varint.ts"; import * as GoAway from "./goaway.ts"; @@ -1590,11 +1590,8 @@ test("Frame object time: draft-15 uses absolute property types", async () => { expect(await props.u62()).toBe(96_000n); expect(await props.done()).toBe(true); - const decoded = await Frame.decode( - new Reader(undefined, encoded, Version.DRAFT_15), - flags, - Timescale.MILLI, - Version.DRAFT_15, + const decoded = await new Reader(undefined, encoded, Version.DRAFT_15).decode((c) => + Frame.decode(c, flags, Timescale.MILLI), ); expect(decoded.timestamp?.value).toBe(96_000); expect(decoded.timestamp?.scale).toBe(Timescale.MILLI); @@ -1658,16 +1655,66 @@ test("Frame object time: draft-16 starts delta property types", async () => { expect(await props.u62()).toBe(96_000n); expect(await props.done()).toBe(true); - const decoded = await Frame.decode( - new Reader(undefined, encoded, Version.DRAFT_16), - flags, - Timescale.MILLI, - Version.DRAFT_16, + const decoded = await new Reader(undefined, encoded, Version.DRAFT_16).decode((c) => + Frame.decode(c, flags, Timescale.MILLI), ); expect(decoded.timestamp?.value).toBe(96_000); expect(decoded.timestamp?.scale).toBe(Timescale.MILLI); }); +test("Frame decodes objects split at every byte", async () => { + const flags: GroupFlags = { + hasExtensions: true, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, + }; + const frames = [ + new Frame({ payload: new Uint8Array([1]), timestamp: new Timestamp(96_000, Timescale.MILLI) }), + new Frame({ payload: new Uint8Array(300).fill(2), timestamp: new Timestamp(96_033, Timescale.MILLI) }), + ]; + const bytes = concatChunks(await Promise.all(frames.map((f) => encodeFrameVersioned(f, flags, Version.DRAFT_20)))); + const reader = new Reader( + new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); + controller.close(); + }, + }), + undefined, + Version.DRAFT_20, + ); + + const decode = (c: Cursor) => Frame.decode(c, flags, Timescale.MILLI); + for (const frame of frames) { + const decoded = await reader.decodeMaybe(decode); + expect(decoded?.payload).toEqual(frame.payload); + expect(decoded?.timestamp?.value).toBe(frame.timestamp?.value); + } + expect(await reader.decodeMaybe(decode)).toBeUndefined(); +}); + +// The properties block is sized on the wire, so a property running past it is malformed, not +// a reason to wait for more of the stream. +test("Frame rejects a property that runs past its block", async () => { + const flags: GroupFlags = { + hasExtensions: true, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, + }; + // Delta 0, a 2-byte block holding a Timestamp id and the first byte of a 2-byte value, a + // 1-byte payload, then bytes the block must not borrow. + const reader = new Reader(undefined, new Uint8Array([0, 2, 0x10, 0x80, 1, 0xaa, 0x01]), Version.DRAFT_20); + await expect(reader.decode((c) => Frame.decode(c, flags, Timescale.MILLI))).rejects.toThrow( + "message is shorter than its fields", + ); +}); + // A fetch stream's first object is the only one carrying absolute ids, so a wrong flag byte // there silently renumbers every object after it. This codec is the sole serialization path // for a served fill. diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index e1ebb8199d..9c895e9e29 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -16,5 +16,6 @@ export * from "./solicit.ts"; export * from "./subscribe.ts"; export * from "./subscribe_namespace.ts"; export * from "./subscriber.ts"; +export * from "./token.ts"; export * from "./track.ts"; export * from "./version.ts"; diff --git a/js/net/src/ietf/object.ts b/js/net/src/ietf/object.ts index 974b44706b..ab37261e0a 100644 --- a/js/net/src/ietf/object.ts +++ b/js/net/src/ietf/object.ts @@ -1,4 +1,4 @@ -import { Reader, Writer } from "../stream.ts"; +import { type Cursor, type Reader, Writer } from "../stream.ts"; import { Timescale, Timestamp } from "../time.ts"; import { type IetfVersion, Version } from "./version.ts"; @@ -100,32 +100,27 @@ async function encodeObjectExtensions( return result; } -async function decodeObjectTime( - r: Reader, - timescale: Timescale, - version: IetfVersion | undefined, -): Promise { +function decodeObjectTime(c: Cursor, timescale: Timescale): Timestamp | undefined { let timestamp: bigint | undefined; let overrideScale: bigint | undefined; let prevType = 0n; let first = true; - while (!(await r.done())) { - const step = await r.u62(); - const id = !hasDeltaObjectPropertyTypes(version) || first ? step : prevType + step; + while (c.remaining > 0) { + const step = c.u62(); + const id = !hasDeltaObjectPropertyTypes(c.version) || first ? step : prevType + step; first = false; prevType = id; if (id % 2n === 0n) { - const value = await r.u62(); + const value = c.u62(); if (id === PROP_TIMESTAMP || id === PROP_TIMESTAMP_DRAFT03) { timestamp = value; } else if (id === PROP_TIMESCALE) { overrideScale = value; } } else { - const size = await r.u53(); - await r.read(size); + c.read(c.u53()); } } @@ -312,41 +307,37 @@ export class Frame { } } - /** Decode a frame using the group flags and negotiated IETF version. */ - static async decode( - r: Reader, - flags: GroupFlags, - timescale: Timescale | undefined, - version = r.version, - ): Promise { + /** Decode a frame using the group flags, at the cursor's negotiated IETF version. */ + static decode(c: Cursor, flags: GroupFlags, timescale: Timescale | undefined): Frame { // The first object's delta is its absolute Object ID; every later one is the prior ID // plus the delta plus one. moq-lite groups start at object 0 and never skip one, so // a sequential group is a zero delta throughout, and any other value means the group // either starts partway through or has a gap that would renumber the frames after it. - const delta = await r.u53(); + const delta = c.u53(); if (delta !== 0) { throw new Error(`object IDs must start at 0 and increment by 1, got a delta of ${delta}`); } let timestamp: Timestamp | undefined; if (flags.hasExtensions) { - const extensionsLength = await r.u53(); - const extensions = await r.read(extensionsLength); + const extensionsLength = c.u53(); // A track that declared no timescale opted out of timestamps, so its objects // are stamped on arrival even if one carries a Timestamp we cannot interpret. if (timescale !== undefined) { - timestamp = await decodeObjectTime(new Reader(undefined, extensions, version), timescale, version); + timestamp = c.exact(extensionsLength, (e) => decodeObjectTime(e, timescale)); + } else { + c.read(extensionsLength); } } - const payloadLength = await r.u53(); + const payloadLength = c.u53(); if (payloadLength > 0) { - const payload = await r.read(payloadLength); + const payload = c.read(payloadLength); return new Frame({ payload, timestamp }); } - const status = await r.u53(); + const status = c.u53(); // Defined on every implemented draft, whether or not the header marks the group's end. if (status === END_OF_TRACK) return new Frame({ endOfTrack: true }); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index dd90579dea..e8281f687c 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -20,7 +20,7 @@ import { PublishNamespace } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; import { RequestError, RequestOk } from "./request.ts"; import { Subscribe, SubscribeOk } from "./subscribe.ts"; -import { SubscribeNamespace } from "./subscribe_namespace.ts"; +import { SubscribeNamespace, SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { TrackStatusRequest } from "./track.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; @@ -258,6 +258,86 @@ test("a blocked group header is reset when the group expires", async () => { } }); +// A group can go stale while its stream is still opening. Serving it must abandon the group +// without starting a write: an abandoned write rejects once the stream resets, and nothing +// would handle it (Node exits on the first unhandled rejection). +test("a group that goes stale while its stream opens writes nothing", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + + const pair = createMockTransportPair(ALPN.DRAFT_19); + let requested!: () => void; + const opening = new Promise((resolve) => { + requested = resolve; + }); + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + let reset!: (reason: unknown) => void; + const streamReset = new Promise((resolve) => { + reset = resolve; + }); + let writes = 0; + const stale = new WritableStream({ + write() { + writes++; + throw new Error("write into an abandoned stream"); + }, + abort: (reason) => reset(reason), + }); + spyOn(pair.server, "createUnidirectionalStream").mockImplementationOnce(async () => { + requested(); + await opened; + return stale; + }); + + const { pub, origin } = publisher(pair.server); + const broadcast = publish(origin, Path.from("test")); + const track = broadcast.createTrack("video", { maxAge: Milli(5000) }); + const client = await Stream.open(pair.client, { version: VERSION }); + const server = await Stream.accept(pair.server, VERSION); + if (!server) throw new Error("publisher never accepted the subscribe stream"); + + try { + process.on("unhandledRejection", onUnhandled); + void pub.runSubscribe( + new Subscribe({ + requestId: 0n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + }), + server, + ); + + const write = (sequence: number, ms: number) => { + const group = new GroupProducer(sequence); + group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); + group.close(); + track.writeGroup(group); + }; + write(0, 0); + await opening; + + // A group beyond the edge, so group 0's reach (where group 1 begins) is provably past + // the budget: a successor alone never convicts it. + write(1, 10_000); + write(2, 20_000); + open(); + + expect(String(await streamReset)).toContain("max age budget"); + expect(writes).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + client.close(); + broadcast.close(); + origin.close(); + } +}); + test.each(["acknowledged", "rejected"] as const)( "a replacement waits until its predecessor's FIN is %s", async (result) => { @@ -532,6 +612,179 @@ test("a solicited legacy advertisement refused once is retried", async () => { origin.close(); }); +/** + * A SUBSCRIBE_NAMESPACE below an advertised route still hears that route: it serves the + * requested prefix, so it lands as the empty suffix, then paths beneath the prefix follow + * as their own suffixes. Matches the Lite publisher and Rust. + */ +test("a subscription below an advertised route hears it as the empty suffix", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); + publish(origin, Path.from("dash")); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("dash/nobody") }), + accepted, + ); + + const entry = async () => { + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + return (await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix; + }; + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + expect(await entry()).toBe(Path.empty()); + + publish(origin, Path.from("dash/nobody/cam")); + expect(await entry()).toBe(Path.from("cam")); + + subscription.close(); + origin.close(); +}); + +/** + * A scoped route covers only what it claims: `room` claiming `room/chat` cannot serve + * `room/video`, so a subscription there must not hear it as the empty suffix. + */ +test("a subscription outside a covering route's claim does not hear it", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); + const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("room/chat"))])); + const dynamic = chat.dynamic(Path.from("room")); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("room/video") }), + accepted, + ); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + + // The first entry is the broadcast beneath the prefix, not the out-of-claim cover. + publish(origin, Path.from("room/video/cam")); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.from("cam")); + + dynamic.close(); + subscription.close(); + origin.close(); +}); + +/** + * A cheaper route claiming `room/chat` does not hide a costlier `room/video` route at the + * same prefix from a subscription at `room/video`. + */ +test("a subscription hears the best covering route its claim allows", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true }); + const scoped = (path: string) => + origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from(path))])); + const chat = scoped("room/chat").dynamic(Path.from("room"), { cost: 1n }); + const video = scoped("room/video").dynamic(Path.from("room"), { cost: 5n }); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("room/video") }), + accepted, + ); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + + // The first entry is the video route as the empty suffix, not a later broadcast beneath it. + publish(origin, Path.from("room/video/cam")); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.empty()); + + video.close(); + chat.close(); + subscription.close(); + origin.close(); +}); + +/** + * A served root collapses every covering route to the empty suffix. A narrow one claiming + * only `tenant/chat` must not hide a broader one from a subscription at `video`. + */ +test("a subscription hears a broader covering route when the narrowest cannot serve it", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const origin = new OriginProducer(); + const tenant = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.all()])); + const pub = new Publisher({ + quic: pair.server, + session: new NativeSession(pair.server, VERSION, true), + publish: tenant.consume(), + requiresSolicitation: true, + }); + const chat = origin + .scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/chat"))])) + .dynamic(Path.from("tenant")); + const broad = origin.dynamic(Path.empty(), { cost: 5n }); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace(new SubscribeNamespace({ requestId: 0n, namespace: Path.from("video") }), accepted); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + + // The first entry is the broad route as the empty suffix, not a later broadcast beneath it. + publish(origin, Path.from("tenant/video/cam")); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.empty()); + + broad.close(); + chat.close(); + subscription.close(); + origin.close(); +}); + +/** + * The claim is presented relative to the served origin's root, like the route's key: a + * publisher serving `tenant` offers `room` (claiming `tenant/room/chat`) to a + * subscription at `room/chat`. + */ +test("a covering route's claim is compared relative to the served root", async () => { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const origin = new OriginProducer(); + const tenant = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.all()])); + const pub = new Publisher({ + quic: pair.server, + session: new NativeSession(pair.server, VERSION, true), + publish: tenant.consume(), + requiresSolicitation: true, + }); + const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.subtree(Path.from("tenant/room/chat"))])); + const dynamic = chat.dynamic(Path.from("tenant/room")); + + const subscription = await Stream.open(pair.client, { version: VERSION }); + const accepted = await Stream.accept(pair.server, VERSION); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace( + new SubscribeNamespace({ requestId: 0n, namespace: Path.from("room/chat") }), + accepted, + ); + + expect(await subscription.reader.u53()).toBe(RequestOk.id); + await RequestOk.decode(subscription.reader, VERSION); + expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); + expect((await SubscribeNamespaceEntry.decode(subscription.reader, VERSION)).suffix).toBe(Path.empty()); + + dynamic.close(); + subscription.close(); + origin.close(); +}); + /** * A peer that refuses an advertisement with a retry interval of 0 is asking not to be * offered it again. Coming back anyway turns a permanent refusal (unauthorized, @@ -913,7 +1166,7 @@ async function readGroup(stream: ReadableStream): Promise): Promise { const reader = new Reader(stream, undefined, V20); const header = await GroupMessage.decode(reader, V20); - const frame = await Frame.decode(reader, header.flags, undefined, V20); + const frame = await reader.decode((c) => Frame.decode(c, header.flags, undefined)); expect(frame.endOfTrack).toBe(true); expect(await reader.done()).toBe(true); return header.groupId; diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 08a486a7b7..23d08bc918 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -3,7 +3,7 @@ import type * as broadcast from "../broadcast.ts"; import { controlTimeout, error, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Route, routesEqual } from "../hop.ts"; -import { hiddenBelow, hooks } from "../internal.ts"; +import { hiddenBelow, hooks, presented } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; @@ -11,7 +11,7 @@ import { Milli, Timescale } from "../time.ts"; import type { Subscriber as TrackSubscriber } from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; -import { type Advertised, wireOf } from "../wire.ts"; +import { type Advertised, type Advertisements, wireOf } from "../wire.ts"; import type { Session } from "./adapter.ts"; import * as Cluster from "./cluster.ts"; import { requestReason, toRequestCode } from "./error.ts"; @@ -161,7 +161,7 @@ export class Publisher { // session leaves its broadcasts alone. The namespaces are advertised with an unsolicited // PUBLISH_NAMESPACE (see {@link runPublishNamespaces}), or on request if the peer asked // for that (see {@link runSubscribeNamespace}). - #advertised: Getter | undefined>; + #advertised: Getter; #publish?: OriginConsumer; // What every advertisement carries on a session that negotiated the MoQ Cluster @@ -518,7 +518,7 @@ export class Publisher { }); try { - await hooks.guardGroup(group, header.encode(stream, this.#session.version)); + await hooks.guardGroup(group, () => header.encode(stream, this.#session.version)); // The first written object goes on the wire as its absolute id, so a trimmed // head shows the true numbering rather than a silently renumbered group. let first = true; @@ -547,8 +547,7 @@ export class Publisher { const obj = new Frame({ payload: read.frame.payload, timestamp: read.frame.timestamp }); const delta = first ? read.sequence : 0; first = false; - await hooks.guardGroup( - group, + await hooks.guardGroup(group, () => obj.encode(stream, header.flags, timescale, this.#session.version, delta), ); } finally { @@ -785,7 +784,7 @@ export class Publisher { // waits for its reply only notifies listeners already registered. // TODO Make a better helper within Signals. let dispose!: Dispose; - const changed = new Promise | undefined>((resolve) => { + const changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); @@ -795,12 +794,7 @@ export class Publisher { break; } - const updated = new Map(); - for (const [covered, snap] of advertised) { - const suffix = Path.stripPrefix(prefix, covered); - if (suffix === null || !carries(covered)) continue; - updated.set(suffix, snap); - } + const updated = presented(prefix, advertised, carries); // A namespace that is gone, or that a republish replaced, takes its refusal with // it: the peer refused a broadcast, not a path forever, so a different one at @@ -915,7 +909,7 @@ export class Publisher { // through it and leave the namespace unadvertised until something unrelated // changed. // TODO Make a better helper within Signals. - const changed = new Promise | undefined>((resolve) => { + const changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); @@ -926,10 +920,10 @@ export class Publisher { } const updated = new Map(); - for (const [covered, snap] of advertised) { + for (const [covered, candidates] of advertised) { // Unasked, a hidden namespace stays off the wire (MoQ Hidden). - if (hiddenBelow(Path.empty(), covered)) continue; - updated.set(covered, snap); + if (hiddenBelow(Path.empty(), covered) || candidates.length === 0) continue; + updated.set(covered, candidates[0]); } // A namespace that is gone, or that a republish replaced, takes its refusal with diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 262fd577d1..d2db53583b 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -974,6 +974,34 @@ test("a group that claims its first object must start at zero", async () => { track.close(); }); +test("every object in a chunk reaches the reader before it wakes", async () => { + const { subscriber, track } = await subscribeTrack(); + + const header = new GroupMessage({ + trackAlias: ALIAS, + groupId: 3, + subGroupId: 0, + publisherPriority: 0, + flags: groupFlags(true), + }); + const objects = encodeObjects(Array.from({ length: 10 }, () => 0)); + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(objects); + controller.close(); + }, + }); + const handled = subscriber.handleGroup(header, new Reader(readable, undefined, VERSION)); + + const group = await track.ordered().nextGroup(); + if (!group) throw new Error("no group"); + expect(await group.readString()).toBe("object 0"); + expect(group.frameCount).toBe(10); + + await handled; + track.close(); +}); + // Hold the actual legacy cancellation write so returning demand lands in the teardown gap. test("returning demand survives a blocked unsubscribe", async () => { const version = Version.DRAFT_16; diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index f228a9cbf0..be024b6627 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -7,7 +7,7 @@ import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; -import type { Reader, Stream } from "../stream.ts"; +import type { Cursor, Reader, Stream } from "../stream.ts"; import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import { Milli, type Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; @@ -1062,18 +1062,18 @@ export class Subscriber { // header priority inherits it (draft-21 section 10.4). if (!group.flags.hasPriority) group.publisherPriority = toWire((await track.info()).priority); + const decode = (c: Cursor) => Frame.decode(c, group.flags, this.#timescales.get(group.trackAlias)); for (;;) { - // Only the group's own stream ends it: a track that closes first has already - // closed (or aborted) this group through its cache. - const done = await (producer ? race([stream.done(), producer.closed]) : stream.done()); - if (done !== false) break; - - const frame = await Frame.decode( - stream, - group.flags, - this.#timescales.get(group.trackAlias), - this.#session.version, - ); + // Every object already buffered is written without an await, so the reader wakes + // once per batch rather than once per object. Only the group's own stream ends it: + // a track that closes first has already closed (or aborted) this group through its + // cache. + const frame = + stream.tryDecode(decode) ?? + (await (producer + ? race([stream.decodeMaybe(decode), producer.closed]) + : stream.decodeMaybe(decode))); + if (!frame || frame instanceof Error) break; if (frame.endOfTrack) { // No object at or past this location exists: after the group's last object diff --git a/js/net/src/ietf/token.test.ts b/js/net/src/ietf/token.test.ts new file mode 100644 index 0000000000..5d4b4137d9 --- /dev/null +++ b/js/net/src/ietf/token.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from "bun:test"; +import { SessionCode, SessionError } from "../error.ts"; +import { Reader, Writer } from "../stream.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, SetupOptions } from "./parameters.ts"; +import { TOKEN_OUT_OF_BAND, type Token, tokenFromSetup, tokenIntoSetup } from "./token.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +const VERSIONS: IetfVersion[] = [ + Version.DRAFT_14, + Version.DRAFT_15, + Version.DRAFT_16, + Version.DRAFT_17, + Version.DRAFT_18, + Version.DRAFT_19, + Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, +]; + +// A kind past one varint byte and a value that is not text, matching the Rust tests. +const TOKEN: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff, 0x03, 0x80, 0x6a]) }; + +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +function varint(v: bigint, version: IetfVersion): Uint8Array { + return leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v); +} + +/** The option as it arrives, after a trip through the SETUP parameter block. */ +async function received(params: SetupOptions, version: IetfVersion): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + version, + ); + await params.encode(writer, version); + writer.close(); + await writer.closed; + + const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return SetupOptions.decode(new Reader(undefined, bytes, version), version); +} + +/** A raw Token structure: varint fields then a value. */ +function structure(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions { + const parts = [...fields.map((field) => varint(field, version)), value]; + const raw = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + raw.set(part, offset); + offset += part.length; + } + const params = new SetupOptions(); + params.setBytes(SetupOption.AuthorizationToken, raw); + return params; +} + +function sessionCode(fn: () => unknown): SessionCode | undefined { + try { + fn(); + } catch (err) { + if (err instanceof SessionError) return err.code; + throw err; + } + return undefined; +} + +test("USE_VALUE round trips on every draft", async () => { + for (const version of VERSIONS) { + const params = new SetupOptions(); + tokenIntoSetup(params, TOKEN, version); + expect(tokenFromSetup(await received(params, version), version)).toEqual(TOKEN); + } +}); + +/** The same bytes `rs/moq-net/src/ietf/token.rs` asserts, so the two agree on the wire. */ +test("the encoding matches the cross-language vector", () => { + const token: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff]) }; + for (const [version, expected] of [ + [Version.DRAFT_14, [0x03, 0x41, 0x2c, 0x00, 0xff]], + [Version.DRAFT_17, [0x03, 0x81, 0x2c, 0x00, 0xff]], + ] as const) { + const params = new SetupOptions(); + tokenIntoSetup(params, token, version); + expect(params.getBytes(SetupOption.AuthorizationToken)).toEqual(new Uint8Array(expected)); + } +}); + +test("an absent option is no token", () => { + for (const version of VERSIONS) { + expect(tokenFromSetup(new SetupOptions(), version)).toBeUndefined(); + } +}); + +test("an empty value is a token", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x3n, TOKEN_OUT_OF_BAND]); + expect(tokenFromSetup(params, version)).toEqual({ kind: TOKEN_OUT_OF_BAND, value: new Uint8Array() }); + } +}); + +/** We advertise no cache, so a registration is the draft's own USE_VALUE. */ +test("REGISTER is a value", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x1n, 7n, TOKEN.kind], TOKEN.value); + expect(tokenFromSetup(params, version)).toEqual(TOKEN); + } +}); + +test("an alias reference is a protocol violation", () => { + for (const version of VERSIONS) { + for (const aliasType of [0x0n, 0x2n]) { + const params = structure(version, [aliasType, 7n]); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.ProtocolViolation); + } + } +}); + +test("an undecodable structure is a formatting error", () => { + for (const version of VERSIONS) { + for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x4n, 0n]]) { + const params = structure(version, fields); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.KeyValueFormatting); + } + } +}); + +/** One credential per connection: a second token is refused, not unioned or dropped. */ +test("two tokens are refused", async () => { + for (const version of VERSIONS) { + const value = [...varint(0x3n, version), ...varint(TOKEN_OUT_OF_BAND, version)]; + const key = SetupOption.AuthorizationToken; + // Delta-encoded from draft-16, so the repeat is a delta of zero. + const repeat = version === Version.DRAFT_14 || version === Version.DRAFT_15 ? key : 0n; + const count = leadingOnes(version) ? [] : [...varint(2n, version)]; + const entry = (k: bigint) => [...varint(k, version), ...varint(BigInt(value.length), version), ...value]; + const bytes = new Uint8Array([...count, ...entry(key), ...entry(repeat)]); + + await expect(SetupOptions.decode(new Reader(undefined, bytes, version), version)).rejects.toThrow( + /duplicate parameter/, + ); + } +}); diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts new file mode 100644 index 0000000000..818466696f --- /dev/null +++ b/js/net/src/ietf/token.ts @@ -0,0 +1,119 @@ +import { SessionCode, SessionError } from "../error.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, type SetupOptions } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +/** + * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). + * + * The value is the Token structure of section 8.9: an Alias Type, then fields that type + * selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means no alias + * is ever registered and every token arrives by value. + * + * Mirrors `rs/moq-net/src/ietf/token.rs`. + * + * @module + * @internal + */ + +/** Retire a registered alias. */ +const DELETE = 0x0n; +/** Register an alias for this type and value, then use them. */ +const REGISTER = 0x1n; +/** Use the type and value a registered alias names. */ +const USE_ALIAS = 0x2n; +/** Use the type and value carried inline. */ +const USE_VALUE = 0x3n; + +/** + * A credential presented in a SETUP's `AUTHORIZATION TOKEN` option. + * + * @internal + */ +export interface Token { + /** The wire Token Type, naming how `value` is encoded. */ + kind: bigint; + /** The token itself. */ + value: Uint8Array; +} + +/** + * Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + * + * @internal + */ +export const TOKEN_OUT_OF_BAND = 0x0n; + +/** + * Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + * + * @internal + */ +export const TOKEN_CAT = 0x1n; + +/** Draft-17 replaced QUIC's two-bit-length varint with a leading-ones one. */ +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +/** + * The token the peer's SETUP presented, if any. + * + * A second token is already refused as a duplicate option by {@link SetupOptions}: one + * credential per connection. + * + * @throws {SessionError} `ProtocolViolation` for an alias reference, which nothing before + * SETUP could have registered, or `KeyValueFormatting` for a structure that cannot be decoded. + * @internal + */ +export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Token | undefined { + const raw = params.getBytes(SetupOption.AuthorizationToken); + if (raw === undefined) return undefined; + + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + const malformed = (cause?: unknown) => + new SessionError(SessionCode.KeyValueFormatting, { cause, reason: "malformed AUTHORIZATION TOKEN" }); + const unvarint = (buf: Uint8Array): [bigint, Uint8Array] => { + try { + return leadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf); + } catch (err) { + throw malformed(err); + } + }; + + let [aliasType, rest] = unvarint(raw); + switch (aliasType) { + case USE_VALUE: + break; + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + case REGISTER: + [, rest] = unvarint(rest); + break; + // Section 9.1.4: nothing can have been registered before SETUP. + case DELETE: + case USE_ALIAS: + throw new SessionError(SessionCode.ProtocolViolation, { reason: "AUTHORIZATION TOKEN alias in SETUP" }); + default: + throw malformed(); + } + + const [kind, value] = unvarint(rest); + return { kind, value: value.slice() }; +} + +/** + * Present `token` in our SETUP, by value. + * + * @internal + */ +export function tokenIntoSetup(params: SetupOptions, token: Token, version: IetfVersion) { + const varint = (v: bigint) => (leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v)); + const aliasType = varint(USE_VALUE); + const kind = varint(token.kind); + + const out = new Uint8Array(aliasType.length + kind.length + token.value.length); + out.set(aliasType, 0); + out.set(kind, aliasType.length); + out.set(token.value, aliasType.length + kind.length); + params.setBytes(SetupOption.AuthorizationToken, out); +} diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 8ab334cc1b..9bba43e7ec 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1448,6 +1448,50 @@ test("integration: lite coalesced fetch stays until every reader abandons the op server.close(); }); +// A fetch that coalesces after the last reader left, but before the FETCH is cancelled, re-arms +// the demand watch. The frame read in flight across that re-arm must still reach the group. The +// window is a few microtasks wide, so the late reader arrives after every delay across it. +test("integration: lite fetch re-armed by a late reader keeps every frame", async () => { + const pair = createMockTransportPair(Lite.ALPN_06); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const video = broadcast.createTrack("video"); + const remote = wireOf(client).consume(Path.from("test")); + + for (let delay = 0; delay <= 12; delay++) { + const group = video.appendGroup(); // open + group.writeString("hello"); + + const f1 = await remote.track("video").fetchGroup(group.sequence); + expect(await f1.readString()).toBe("hello"); + + f1.close(); + for (let i = 0; i < delay; i++) await Promise.resolve(); + const f2 = await remote.track("video").fetchGroup(group.sequence); + expect(await f2.readString()).toBe("hello"); + + group.writeString("more"); + const more = await Promise.race([ + f2.readString(), + new Promise((resolve) => setTimeout(() => resolve("dropped"), 500)), + ]); + expect(`${delay}: ${more}`).toBe(`${delay}: more`); + + f2.close(); + group.close(); + } + + broadcast.close(); + remote.close(); + client.close(); + server.close(); +}); + // A finite group must still deliver every frame and end cleanly (the demand watch must not disturb // normal completion), exercising the per-frame loop many times. test("integration: lite fetch delivers every frame of a finite multi-frame group", async () => { diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 3f235938cd..db88b3b90d 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -6,12 +6,13 @@ * @module */ import type { Dispose, Getter } from "@moq/signals"; -import type { Producer as BroadcastProducer } from "./broadcast.ts"; +import type { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import type { Frame, Consumer as GroupConsumer } from "./group.ts"; import type { Route } from "./hop.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; import type { Groups, Producer, Request, Subscriber } from "./track.ts"; +import type { Advertised, Advertisements } from "./wire.ts"; /** Normalize public group bounds into an inclusive start and exclusive end. */ export function groupBounds(groups: Groups = {}): { start: number; end?: number } { @@ -46,6 +47,36 @@ export function hiddenBelow(prefix: Path.Valid, path: Path.Valid): boolean { return below !== null && Path.parts(below).some((part) => part.startsWith(".")); } +/** + * Where each carried route lands under the requested prefix: its suffix beneath the + * prefix, or the empty suffix for a route above it, where the most specific such route + * wins the way a request through the prefix would resolve. + */ +export function presented( + prefix: Path.Valid, + table: Advertisements, + carries: (covered: Path.Valid) => boolean, +): Map { + const out = new Map(); + let rootLen = -1; + const requested = Path.Pattern.subtree(prefix); + for (const [covered, candidates] of table) { + if (!carries(covered)) continue; + if (Path.hasPrefix(covered, prefix)) { + if (covered.length < rootLen) continue; + // A scoped route covers only what it claims, so the best one that can serve the prefix wins. + const snap = candidates.find((candidate) => !candidate.claim || candidate.claim.overlaps(requested)); + if (!snap) continue; + rootLen = covered.length; + out.set(Path.empty(), snap); + continue; + } + const suffix = Path.stripPrefix(prefix, covered); + if (suffix !== null && candidates.length > 0) out.set(suffix, candidates[0]); + } + return out; +} + /** Whether the announced prefix's subtree overlaps `scope`. */ export function scopeOverlaps(scope: Path.Pattern, prefix: Path.Valid): boolean { return scope.overlaps(Path.Pattern.subtree(prefix)); @@ -137,8 +168,8 @@ export const hooks: { group: GroupConsumer, expiry: { expired: () => boolean; changed: readonly Getter[] }, ) => void; - /** Stop an in-flight group operation if the handed-out group expires. */ - guardGroup: (group: GroupConsumer, operation: Promise) => Promise; + /** Start a group operation unless the handed-out group has expired, and stop it if the group expires mid-flight. */ + guardGroup: (group: GroupConsumer, operation: () => Promise) => Promise; /** Read a frame the wire publisher completes (or skips) once written. */ readGroupFrame: (group: GroupConsumer, from?: number) => Promise; /** Make an evicted mirror terminal while its track timeline still contains it. */ @@ -148,6 +179,8 @@ export const hooks: { producer: BroadcastProducer, announcer: { announce(route: Route): void; unannounce(): void }, ) => void; + /** Name a broadcast handle by the path an origin created or resolved it at. */ + stampPath: (target: BroadcastProducer | BroadcastConsumer, path: Path.Valid) => void; } = { makeRequest: () => { throw new Error("track.ts not loaded"); @@ -188,4 +221,7 @@ export const hooks: { attachAnnouncer: () => { throw new Error("broadcast.ts not loaded"); }, + stampPath: () => { + throw new Error("broadcast.ts not loaded"); + }, }; diff --git a/js/net/src/lite/group.test.ts b/js/net/src/lite/group.test.ts new file mode 100644 index 0000000000..e0df4212ed --- /dev/null +++ b/js/net/src/lite/group.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { Producer } from "../group.ts"; +import { Reader } from "../stream.ts"; +import * as Varint from "../varint.ts"; +import { readFrames } from "./group.ts"; + +const SCALE = 1000; + +/** Frames as a group stream carries them: a zigzag timestamp delta when timestamped, then the sized payload. */ +function encode(frames: { delta?: number; payload: number[] }[]): Uint8Array { + const bytes: number[] = []; + for (const { delta, payload } of frames) { + if (delta !== undefined) bytes.push(...Varint.encode(delta < 0 ? -2 * delta - 1 : 2 * delta)); + bytes.push(...Varint.encode(payload.length), ...payload); + } + return new Uint8Array(bytes); +} + +function streamOf(chunks: Uint8Array[]): Reader { + return new Reader( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }), + ); +} + +test("every frame in a chunk reaches the reader before it wakes", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + const frames = Array.from({ length: 10 }, (_, index) => ({ delta: 1, payload: [index] })); + const done = readFrames(streamOf([encode(frames)]), producer, SCALE); + + expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([0])); + expect(consumer.frameCount).toBe(10); + await done; +}); + +test("frames split at every byte keep their payloads and timestamps", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + // Deltas wide enough for multi-byte varints, and one that steps back. + const frames = [ + { delta: 100, payload: [1] }, + { delta: 20_000, payload: [2, 2] }, + { delta: -50, payload: [] }, + { delta: 0, payload: Array.from({ length: 300 }, () => 4) }, + ]; + const bytes = encode(frames); + const chunks = Array.from(bytes, (byte) => new Uint8Array([byte])); + + await readFrames(streamOf(chunks), producer, SCALE); + producer.close(); + + let ts = 0; + for (const { delta, payload } of frames) { + const frame = await consumer.readFrame(); + ts += delta; + expect(frame?.payload).toEqual(new Uint8Array(payload)); + expect(frame?.timestamp.value).toBe(ts); + } + expect(await consumer.readFrame()).toBeUndefined(); +}); + +test("frames without a timescale carry no timestamp prefix", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + await readFrames(streamOf([encode([{ payload: [7] }, { payload: [8, 9] }])]), producer, 0); + producer.close(); + + expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([7])); + expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([8, 9])); + expect(await consumer.readFrame()).toBeUndefined(); +}); + +test("a stream that ends inside a frame rejects", async () => { + const producer = new Producer(0); + const bytes = encode([{ delta: 1, payload: [1, 2, 3] }]); + await expect(readFrames(streamOf([bytes.subarray(0, bytes.byteLength - 1)]), producer, SCALE)).rejects.toThrow( + "unexpected end of stream", + ); +}); + +test("stops once the group closes", async () => { + const producer = new Producer(0); + // Never ends: only the close can stop the read. + const stream = new Reader(new ReadableStream()); + const done = readFrames(stream, producer, SCALE); + producer.close(); + await done; +}); diff --git a/js/net/src/lite/group.ts b/js/net/src/lite/group.ts index 598b2893bf..2bb058051f 100644 --- a/js/net/src/lite/group.ts +++ b/js/net/src/lite/group.ts @@ -1,4 +1,7 @@ -import type { Reader, Writer } from "../stream.ts"; +import { race } from "@moq/signals"; +import type * as netGroup from "../group.ts"; +import type { Cursor, Reader, Writer } from "../stream.ts"; +import * as Time from "../time.ts"; import * as Message from "./message.ts"; import { hasFrameBounds, type Version } from "./version.ts"; @@ -61,27 +64,44 @@ export class Group { } } -export class Frame { - payload: Uint8Array; - - constructor(payload: Uint8Array) { - this.payload = payload; - } +/** Decode an unsigned zigzag varint back to a signed delta (mirrors Rust `VarInt::to_zigzag`). */ +function unzigzag(v: bigint): bigint { + return (v >> 1n) ^ -(v & 1n); +} - async #encode(w: Writer) { - await w.write(this.payload); +/** + * A synchronous decode for one frame of a group or FETCH response stream. + * + * A non-zero `scale` means every frame is prefixed with a zigzag-delta timestamp (the lite-05 + * FRAME format), decoded into a Timestamp at that scale. Scale 0 (pre-lite-05) carries no + * timestamp, so frames are wall-clock stamped on arrival. + */ +export function frameDecoder(scale: number): (c: Cursor) => netGroup.Frame { + if (scale === 0) { + return (c) => ({ payload: c.read(c.u53()), timestamp: Time.Timestamp.now() }); } - static async #decode(r: Reader): Promise { - const payload = await r.readAll(); - return new Frame(payload); - } + const timescale = Time.Timescale(scale); + let prevTs = 0n; + return (c) => { + const delta = unzigzag(c.u62()); + const payload = c.read(c.u53()); + // After the last read, so a decode that ran short and gets retried adds the delta once. + prevTs += delta; + return { payload, timestamp: new Time.Timestamp(Number(prevTs), timescale) }; + }; +} - async encode(w: Writer): Promise { - return Message.encode(w, this.#encode.bind(this)); - } +/** Write a group stream's frames into `producer` until the stream ends or the producer closes. */ +export async function readFrames(stream: Reader, producer: netGroup.Producer, scale: number): Promise { + const decode = frameDecoder(scale); - static async decode(r: Reader): Promise { - return Message.decode(r, Frame.#decode); + for (;;) { + // Every frame already buffered is written without an await, so the reader wakes once + // per batch rather than once per frame. Only the group's own stream ends it: a track + // that closes first has already closed (or aborted) this group through its cache. + const frame = stream.tryDecode(decode) ?? (await race([stream.decodeMaybe(decode), producer.closed])); + if (!frame || frame instanceof Error) return; + producer.writeFrame(frame); } } diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 301390560a..5f322c1095 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1524,3 +1524,81 @@ test("a version without the latency field serves a non-dropping budget", async ( } } }); + +// A group can go stale while its stream is still opening. Serving it must abandon the group +// without starting a write: an abandoned write rejects once the stream resets, and nothing +// would handle it (Node exits on the first unhandled rejection). +test("lite draft-05: a group that goes stale while its stream opens writes nothing", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + + const pair = createMockTransportPair(ALPN_05); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomHop(), origin.consume()); + const broadcast = publish(origin, Path.from("test")); + const track = broadcast.createTrack("video"); + + let requested!: () => void; + const opening = new Promise((resolve) => { + requested = resolve; + }); + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + let reset!: (reason: unknown) => void; + const streamReset = new Promise((resolve) => { + reset = resolve; + }); + let writes = 0; + const stale = new WritableStream({ + write() { + writes++; + throw new Error("write into an abandoned stream"); + }, + abort: (reason) => reset(reason), + }); + spyOn(pair.server, "createUnidirectionalStream").mockImplementationOnce(async () => { + requested(); + await opened; + return stale; + }); + + const client = await Stream.open(pair.client); + const server = await Stream.accept(pair.server); + if (!server) throw new Error("publisher never accepted the subscribe stream"); + + try { + process.on("unhandledRejection", onUnhandled); + void publisher.runSubscribe( + new Subscribe({ id: 0n, broadcast: Path.from("test"), track: "video", priority: 0, maxAge: 100 }), + server, + ); + + const write = (sequence: number, ms: number) => { + const group = new GroupProducer(sequence); + group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); + group.close(); + track.writeGroup(group); + }; + write(0, 0); + await opening; + + // A group beyond the edge, so group 0's reach (where group 1 begins) is provably past + // the budget: a successor alone never convicts it. + write(1, 10_000); + write(2, 20_000); + open(); + + expect(String(await streamReset)).toContain("max age budget"); + expect(writes).toBe(0); + await flush(); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + publisher.close(); + client.close(); + broadcast.close(); + origin.close(); + } +}); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index f35fce72be..ed2a854dd8 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -3,13 +3,13 @@ import type * as broadcast from "../broadcast.ts"; import { error, NotFound, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; -import { hiddenBelow, hooks } from "../internal.ts"; +import { hiddenBelow, hooks, presented } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; -import * as Path from "../path.ts"; +import type * as Path from "../path.ts"; import { type Reader, type Stream, Writer } from "../stream.ts"; import { Milli, Timescale } from "../time.ts"; import type * as track from "../track.ts"; -import { type Advertised, wireOf } from "../wire.ts"; +import { type Advertised, type Advertisements, wireOf } from "../wire.ts"; import { AnnounceInit, AnnounceOk, type AnnounceRequest, encodeAnnounceBroadcast } from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; @@ -37,31 +37,6 @@ import { Version, } from "./version.ts"; -// Where each originated route lands under the requested prefix: its suffix beneath -// the prefix, or the empty suffix for a route above it, where the most specific -// such route wins the way a request through the prefix would resolve. -function presented( - prefix: Path.Valid, - table: ReadonlyMap, - hidden: boolean, -): Map { - const out = new Map(); - let rootLen = -1; - for (const [covered, snap] of table) { - if (Path.hasPrefix(covered, prefix)) { - if (covered.length < rootLen) continue; - rootLen = covered.length; - out.set(Path.empty(), snap); - continue; - } - // A hidden route stays off the wire unless the request opted in. - if (!hidden && hiddenBelow(prefix, covered)) continue; - const suffix = Path.stripPrefix(prefix, covered); - if (suffix !== null) out.set(suffix, snap); - } - return out; -} - const PROBE_INTERVAL = 100; // ms const PROBE_MAX_AGE = 10_000; // ms const PROBE_MAX_DELTA = 0.25; @@ -365,7 +340,7 @@ export class Publisher { #datagramWriter?: WritableStreamDefaultWriter; // Originated advertisements this session forwards. - #advertised: Getter | undefined>; + #advertised: Getter; #publish?: OriginConsumer; @@ -475,15 +450,18 @@ export class Publisher { // unrelated moved. // TODO Make a better helper within Signals. let dispose!: Dispose; - let changed = new Promise | undefined>((resolve) => { + let changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); + // A hidden route stays off the wire unless the request opted in. + const carries = (covered: Path.Valid) => msg.hidden || !hiddenBelow(msg.prefix, covered); + try { const initial = this.#advertised.peek(); if (!initial) return; // closed - for (const [name, snap] of presented(msg.prefix, initial, msg.hidden)) { + for (const [name, snap] of presented(msg.prefix, initial, carries)) { active.set(name, snap); } @@ -520,7 +498,7 @@ export class Publisher { if (!advertised) break; // Re-arm before reading, so an advertise that lands while we write is not lost. - changed = new Promise | undefined>((resolve) => { + changed = new Promise((resolve) => { dispose = this.#advertised.changed(resolve); }); @@ -528,7 +506,7 @@ export class Publisher { if (!latest) break; const updated = new Map(); - for (const [name, snap] of presented(msg.prefix, latest, msg.hidden)) { + for (const [name, snap] of presented(msg.prefix, latest, carries)) { updated.set(name, snap); } @@ -950,7 +928,7 @@ export class Publisher { } const cached = tracks.get(track); - if (cached) return cached; + if (cached !== undefined) return cached; const pending = (async () => { const info = await wireOf(front).resolveTrackInfo(track); @@ -1084,13 +1062,10 @@ export class Publisher { // follows it too rather than keeping a stale rank until it finishes. priority.add(stream, group.sequence); - await hooks.guardGroup( - group, - (async () => { - await stream.u53(0); // stream type - await msg.encode(stream, this.version); - })(), - ); + await hooks.guardGroup(group, async () => { + await stream.u53(0); // stream type + await msg.encode(stream, this.version); + }); // Lite05+ prefixes every frame with a zigzag-delta timestamp at the track's // advertised timescale; older drafts omit it. @@ -1122,12 +1097,12 @@ export class Publisher { if (timestamps) { // Convert each frame to the track's advertised timescale. const ts = BigInt(Math.round(read.frame.timestamp.as(timescale))); - await hooks.guardGroup(group, stream.u62(zigzag(ts - prevTs))); + await hooks.guardGroup(group, () => stream.u62(zigzag(ts - prevTs))); prevTs = ts; } - await hooks.guardGroup(group, stream.u53(read.frame.payload.byteLength)); - await hooks.guardGroup(group, stream.write(read.frame.payload)); + await hooks.guardGroup(group, () => stream.u53(read.frame.payload.byteLength)); + await hooks.guardGroup(group, () => stream.write(read.frame.payload)); } finally { read.complete(); } diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index b7d76e5c3a..3d8d525478 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { Signal } from "@moq/signals"; import type { Probe as ProbeStats } from "../connection/stats.ts"; -import { error, reason } from "../error.ts"; +import { error, reason, StreamCode, StreamError } from "../error.ts"; import { HopSchema, isAnonymous, MAX_HOPS, Route, UNKNOWN_HOP } from "../hop.ts"; import * as Path from "../path.ts"; import { Writer } from "../stream.ts"; @@ -9,6 +9,7 @@ import * as Time from "../time.ts"; import { type AnnounceBroadcast, AnnounceInit, AnnounceOk, encodeAnnounceBroadcast } from "./announce.ts"; import { Probe } from "./probe.ts"; import { Subscriber } from "./subscriber.ts"; +import { TrackInfo } from "./track.ts"; import { Version } from "./version.ts"; /** The next route event, skipping the live marker: these tests pin routes, and the marker has its own. */ @@ -774,3 +775,114 @@ test("a draft-04 initial set is live once the stream goes quiet", async () => { announced.close(); subscriber.close(); }); + +interface FakeStream { + inbound: ReadableStreamDefaultController; + // Resolves once the subscriber waits on a read the test has not answered. + reading: Promise; + aborted: Promise; + // Hands the stream to the subscriber, for an open the session was told to park. + release: () => void; +} + +// A session whose streams the test answers by hand and that never fails them on its own, so +// only Subscriber.close() can end a wait. Opens numbered in `park` wait for `release()`. +function fakeSession(park: number[] = []) { + const streams: FakeStream[] = []; + const quic = { + createBidirectionalStream: () => { + let inbound!: ReadableStreamDefaultController; + let onRead!: () => void; + let onAbort!: (reason: unknown) => void; + let release!: () => void; + const reading = new Promise((resolve) => (onRead = resolve)); + const aborted = new Promise((resolve) => (onAbort = resolve)); + // No high water mark, so pull() means the subscriber is blocked on a read. + const readable = new ReadableStream( + { + start: (controller) => { + inbound = controller; + }, + pull: () => onRead(), + }, + { highWaterMark: 0 }, + ); + const writable = new WritableStream({ abort: (reason) => void onAbort(reason) }); + const opened = new Promise((resolve) => (release = () => resolve({ readable, writable }))); + if (!park.includes(streams.length)) release(); + streams.push({ inbound, reading, aborted, release }); + return opened; + }, + } as unknown as WebTransport; + return { quic, streams }; +} + +async function answerTrackInfo(stream: FakeStream): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ write: (chunk) => void chunks.push(new Uint8Array(chunk)) }), + ); + await new TrackInfo({}).encode(writer, Version.DRAFT_05); + for (const chunk of chunks) stream.inbound.enqueue(chunk); + stream.inbound.close(); +} + +function expectCut(err: unknown, cause: Error | undefined) { + if (cause) { + expect(err).toBe(cause); + } else { + expect(err).toBeInstanceOf(StreamError); + expect((err as StreamError).code).toBe(StreamCode.SessionClosed); + } +} + +// Lite has no FETCH_OK, so a publisher that never answers holds each setup stage until the +// subscriber closes. The stream that stage opened is reset, even one opening after the close. +test.each([ + ["the TRACK_INFO", "track", undefined], + ["the FETCH", "fetch", undefined], + ["the FETCH, on a session error", "fetch", new Error("session died")], + ["a stream slot for the FETCH", "open", undefined], +] as const)("closing the subscriber rejects a fetch waiting on %s", async (_, stage, cause) => { + const { quic, streams } = fakeSession(stage === "open" ? [1] : []); + const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n)); + + let settled = false; + const fetch = subscriber.fetchGroup(Path.from("room"), "video", 0).then( + () => { + settled = true; + return undefined; + }, + (err: unknown) => { + settled = true; + return err; + }, + ); + + await drainUntil(() => streams.length === 1); + if (stage === "track") { + await streams[0].reading; + } else { + await answerTrackInfo(streams[0]); + await drainUntil(() => streams.length === 2); + if (stage === "fetch") await streams[1].reading; + } + expect(settled).toBe(false); + + subscriber.close(cause); + expectCut(await fetch, cause); + + const stuck = streams[stage === "track" ? 0 : 1]; + stuck.release(); + await stuck.aborted; +}); + +test("a fetch started after the subscriber closes rejects without opening a stream", async () => { + const { quic, streams } = fakeSession(); + const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n)); + subscriber.close(); + + const err = await subscriber.fetchGroup(Path.from("room"), "video", 0).catch((err: unknown) => err); + expectCut(err, undefined); + expect(streams.length).toBe(0); +}); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 0a32bd0afe..2a1fd389f6 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -8,7 +8,7 @@ import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { groupBounds, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; -import { type Reader, Stream } from "../stream.ts"; +import { type OpenOptions, type Reader, Stream } from "../stream.ts"; import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; @@ -24,7 +24,7 @@ import { import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; import { Fetch as FetchMessage } from "./fetch.ts"; -import type { Group as GroupMessage } from "./group.ts"; +import { frameDecoder, type Group as GroupMessage, readFrames } from "./group.ts"; import { sendOrder } from "./priority.ts"; import { Probe } from "./probe.ts"; import { ProbeLevel, type Setup } from "./setup.ts"; @@ -40,7 +40,15 @@ import { SubscribeUpdate, } from "./subscribe.ts"; import { TrackInfo, Track as TrackMessage } from "./track.ts"; -import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, restartSupported, Version } from "./version.ts"; +import { + hasAnnounceId, + hasAnnounceOk, + hasDatagrams, + hasProbeRtt, + hasStreamCount, + restartSupported, + Version, +} from "./version.ts"; // Bound on how long stream-open plus the first response (SUBSCRIBE_OK on older // drafts, or TRACK_INFO on lite-05+) may take. Browsers cap concurrent QUIC streams @@ -48,11 +56,6 @@ import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, restartSupport // until the peer frees a slot. The timeout turns a stall into a clear error. const SUBSCRIBE_SETUP_TIMEOUT_MS = 10_000; -/** Decode an unsigned zigzag varint back to a signed delta (mirrors Rust `VarInt::to_zigzag`). */ -function unzigzag(v: bigint): bigint { - return (v >> 1n) ^ -(v & 1n); -} - // The TRACK stream and implicit SUBSCRIBE acceptance are lite-05+. function supportsTrackStream(version: Version): boolean { switch (version) { @@ -83,6 +86,8 @@ interface SubscribeEntry { // (SUBSCRIBE_END), once it declares them. start?: number; end?: number; + // Group streams opened by the publisher, when SUBSCRIBE_END carries the count. + streams?: number; } /** @@ -692,17 +697,33 @@ export class Subscriber { // Opens a TRACK stream, reads the single TRACK_INFO, and FINs. Lite-05+ only. async #trackInfo(broadcast: Path.Valid, track: string): Promise { - const stream = await Stream.open(this.#quic); - try { + return this.#exchange(undefined, async (stream) => { await stream.writer.u53(StreamId.Track); await new TrackMessage(broadcast, track).encode(stream.writer, this.version); const info = await TrackInfo.decode(stream.reader, this.version); // The publisher FINs after TRACK_INFO; FIN our side too. stream.close(); return info; + }); + } + + // Opens a stream and runs a request/response exchange on it, resetting the stream if `run` + // fails. Subscriber.close() also resets it while `run` is pending, so a peer that never + // answers cannot hold it open, and a stream that opens after the close is reset at once. + async #exchange(options: OpenOptions | undefined, run: (stream: Stream) => Promise): Promise { + const closed = this.#closed.signal; + closed.throwIfAborted(); + const stream = await Stream.open(this.#quic, options); + const abort = () => stream.abort(error(closed.reason)); + closed.addEventListener("abort", abort); + try { + closed.throwIfAborted(); + return await run(stream); } catch (err) { stream.abort(error(err)); throw err; + } finally { + closed.removeEventListener("abort", abort); } } @@ -774,61 +795,82 @@ export class Subscriber { throw new Error("fetch group requires moq-lite-05 or newer"); } - const info = await this.#trackInfo(broadcast, track); - const priority = options.priority ?? 0; - const stream = await Stream.open(this.#quic, { sendOrder: sendOrder({ priority }) }); - + // Lite has no FETCH_OK, so a publisher that never answers would hold the setup forever. + // Subscriber.close() closing the group releases every caller at any stage, and resets + // the streams the setup opened. + const setup = this.#fetchSetup(broadcast, track, sequence, options); + let accepted: { stream: Stream; info: TrackInfo }; try { - await stream.writer.u53(StreamId.Fetch); - await new FetchMessage({ broadcast, track, priority, group: sequence }).encode( - stream.writer, - this.version, - ); - // A byte or an empty-group FIN accepts the fetch; a reset rejects it. - // done() buffers that byte so the response pump can decode it normally. - await stream.reader.done(); + accepted = await untilClosed(group, setup); } catch (err: unknown) { - stream.abort(error(err)); + // A setup that finishes just after the close hands back a stream nobody will read. + void setup.then( + ({ stream }) => stream.abort(error(err)), + () => void 0, + ); throw err; } - void this.#runFetchResponse(stream, group, Time.Timescale(info.timescale)); + void this.#runFetchResponse(accepted.stream, group, Time.Timescale(accepted.info.timescale)); } catch (err: unknown) { group.close(error(err)); throw err; } } + // Resolve the track's timescale, then open the FETCH stream and wait for it to be accepted. + async #fetchSetup( + broadcast: Path.Valid, + track: string, + sequence: number, + options: track.FetchGroupOptions, + ): Promise<{ stream: Stream; info: TrackInfo }> { + const info = await this.#trackInfo(broadcast, track); + const priority = options.priority ?? 0; + return this.#exchange({ sendOrder: sendOrder({ priority }) }, async (stream) => { + await stream.writer.u53(StreamId.Fetch); + await new FetchMessage({ broadcast, track, priority, group: sequence }).encode(stream.writer, this.version); + // A byte or an empty-group FIN accepts the fetch; a reset rejects it. + // done() buffers that byte so the response pump can decode it normally. + await stream.reader.done(); + return { stream, info }; + }); + } + // Read the FETCH response (bare zigzag-delta-timestamped frames) into the group, then // FIN. A stream-level failure aborts the group so its reader observes the gap. async #runFetchResponse(stream: Stream, group: netGroup.Producer, timescale: Time.Timescale): Promise { try { - let prevTs = 0n; + const decode = frameDecoder(timescale); // Serve until the stream FINs, the group closes, or every reader leaves. A group can // stay open indefinitely (a catalog or JSON stream), so an abandoned fetch is stopped by // demand, not by the stream ending. `unused` is watched across frames as one stable // promise; the check is level-triggered, so a coalesced fetch that arrives before we // cancel re-arms and resumes. - const idle = Symbol("idle"); - let unused = group.unused().then(() => idle); + const idle: unique symbol = Symbol("idle"); + let unused = group.unused().then((): typeof idle => idle); + // A decode consumes its frame whenever it lands, so one outstanding across a re-arm is + // kept and awaited again rather than abandoned with its frame. + let pending: Promise | undefined; for (;;) { - const done = await race([stream.reader.done(), group.closed, unused]); - if (done === idle) { - if (!group.isClosed && group.used.peek()) { - unused = group.unused().then(() => idle); - continue; + // Buffered frames are written without an await, as in a group stream. + let frame = pending === undefined ? stream.reader.tryDecode(decode) : undefined; + if (!frame) { + pending ??= stream.reader.decodeMaybe(decode); + const next = await race([pending, group.closed, unused]); + if (next === idle) { + if (!group.isClosed && group.used.peek()) { + unused = group.unused().then((): typeof idle => idle); + continue; + } + break; } - break; + pending = undefined; + if (!next || next instanceof Error) break; + frame = next; } - if (done !== false) break; - - prevTs += unzigzag(await stream.reader.u62()); - const timestamp = new Time.Timestamp(Number(prevTs), timescale); - const size = await stream.reader.u53(); - const payload = await stream.reader.read(size); - if (!payload) break; - group.writeFrame({ payload, timestamp }); + group.writeFrame(frame); } group.close(); @@ -858,6 +900,7 @@ export class Subscriber { } else if ("end" in resp) { if (entry.end !== undefined) throw new ProtocolViolation("duplicate SUBSCRIBE_END"); entry.end = resp.end.group; + if (hasStreamCount(this.version)) entry.streams = resp.end.streams; // A local close can win the race with the response; there is nothing left to end. if (entry.track.closed.peek() !== undefined) continue; try { @@ -873,13 +916,10 @@ export class Subscriber { // Wait for the group streams the publisher still owes once it has ended the subscription. // - // Its FIN says every group below the end is accounted for, but QUIC does not order streams, - // so one can still be in flight. Wait until each group from SUBSCRIBE_START to the end has - // a stream (read to its end) or a SUBSCRIBE_DROP. A group reset before its header arrived - // never shows up, so give up on missing groups after the subscription's effective max age, - // then end cleanly with them skipped like any stale group. That is a wall-clock stopgap for - // a presentation-time budget; a publisher sending SUBSCRIBE_DROP for every group it reset - // would account for them with no timer at all. + // lite-07 counts streams, so skipped sequences owe nothing. Older drafts account for + // the range using received headers and SUBSCRIBE_DROP. A counted stream reset before + // its header leaves no trace, so the grace still bounds that wait. Streams whose + // headers arrived keep reading until their own FIN or reset. #settleTail(entry: SubscribeEntry): Promise { const { tail, track } = entry; // Already the smaller of the subscriber's and the track's max age. @@ -887,6 +927,7 @@ export class Subscriber { const grace = maxAge > 0 ? maxAge : TAIL_GRACE_MS; const complete = () => { + if (entry.streams !== undefined) return tail.streams >= entry.streams; // Without SUBSCRIBE_END (older drafts) nothing says which groups are owed. if (entry.end === undefined) return false; // Without SUBSCRIBE_START the publisher served no group at all. @@ -1005,31 +1046,7 @@ export class Subscriber { scale = timescale.peek(); } - // A non-zero scale means every frame is prefixed with a zigzag-delta timestamp - // (the lite-05 FRAME format), which we decode into a Timestamp at that scale. - // Scale 0 (pre-lite-05) carries no timestamp, so we wall-clock-stamp. - let prevTs = 0n; - - for (;;) { - // Only the group's own stream ends it: a track that closes first has already - // closed (or aborted) this group through its cache. - const done = await race([stream.done(), producer.closed]); - if (done !== false) break; - - let timestamp: Time.Timestamp; - if (scale !== 0) { - prevTs += unzigzag(await stream.u62()); - timestamp = new Time.Timestamp(Number(prevTs), Time.Timescale(scale)); - } else { - timestamp = Time.Timestamp.now(); - } - - const size = await stream.u53(); - const payload = await stream.read(size); - if (!payload) break; - - producer.writeFrame({ payload, timestamp }); - } + await readFrames(stream, producer, scale); producer.close(); stream.stop(new StreamError(StreamCode.Cancel, { message: "cancel" })); @@ -1176,16 +1193,33 @@ export class Subscriber { * session died, since those tracks were cut off rather than ended. */ close(err?: Error) { - this.#closed.abort(); + // A fetch or setup exchange cut off by the session is incomplete even on a deliberate + // close, so it always ends with an error. + const cut = err ?? new StreamError(StreamCode.SessionClosed, { message: "session closed" }); + this.#closed.abort(cut); for (const { track } of this.#subscribes.values()) { track.close(err); } this.#subscribes.clear(); + + // This also releases callers still awaiting acceptance. + for (const { group } of this.#fetches.values()) { + group.close(cut); + } } } +// Settles with `step`, or rejects with the group's error once it closes first. A publisher +// may never answer a FETCH, so Subscriber.close() closing the group is what releases it. +async function untilClosed(group: netGroup.Producer, step: Promise): Promise { + const value = await race([step, group.closed]); + const closed = group.closed.peek(); + if (closed !== undefined) throw closed ?? new Error("fetch closed before it was accepted"); + return value as T; +} + /** * A broadcast consumed from a lite session. It resolves `track.Consumer.query()` and * `.fetchGroup()` over the wire (lite-05+ TRACK / FETCH streams) by reaching into the diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index 4edfe88c5c..245791287d 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { describe, 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"; @@ -20,12 +20,10 @@ import { Subscriber } from "./subscriber.ts"; import { TrackInfo, Track as TrackMessage } from "./track.ts"; import { ALPN_05, Version } from "./version.ts"; -const VERSION = Version.DRAFT_05; - // The subscription's max age, which is also how long it waits for a group that never arrives. const GRACE = Milli(100); -/** One lite-05 frame: a zero timestamp delta, then the length-prefixed payload. */ +/** One lite-05+ frame: a zero timestamp delta, then the length-prefixed payload. */ function frame(payload: string): Uint8Array { const bytes = new TextEncoder().encode(payload); // Every field is under 64, so each is a one-byte varint. @@ -49,31 +47,31 @@ function groupStream(subscriber: Subscriber, sequence: number) { } /** - * A lite-05 subscriber with one track subscribed, whose publisher the test plays by hand: + * A lite-05+ subscriber with one track subscribed, whose publisher the test plays by hand: * it answers TRACK_INFO, then writes whatever responses the test asks for on the subscribe * stream and FINs it when told. */ -async function subscribed(maxAge = GRACE) { +async function subscribed(version: Version, maxAge = GRACE) { const pair = createMockTransportPair(ALPN_05); - const subscriber = new Subscriber(pair.client, VERSION, randomHop()); + const subscriber = new Subscriber(pair.client, version, randomHop()); const reader = subscriber.consume(Path.from("room")).track("video").subscribe({ maxAge }); const info = await Stream.accept(pair.server); if (!info) throw new Error("the subscriber never asked for TRACK_INFO"); expect(await info.reader.u53()).toBe(StreamId.Track); - await TrackMessage.decode(info.reader, VERSION); - await new TrackInfo({ maxAge: 60_000 }).encode(info.writer, VERSION); + await TrackMessage.decode(info.reader, version); + await new TrackInfo({ maxAge: 60_000 }).encode(info.writer, version); info.close(); const sub = await Stream.accept(pair.server); if (!sub) throw new Error("the subscriber never subscribed"); expect(await sub.reader.u53()).toBe(StreamId.Subscribe); - await Subscribe.decode(sub.reader, VERSION); + await Subscribe.decode(sub.reader, version); return { subscriber, reader, - respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, VERSION), + respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, version), fin: () => sub.writer.close(), reset: (error: Error) => sub.writer.reset(error), }; @@ -103,126 +101,160 @@ async function settlesWithin(promise: Promise, ms: number): Promise { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const first = groupStream(subscriber, 0); - first.write("0.0"); - first.finish(); - await respond({ end: new SubscribeEnd(2) }); - await fin(); +describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (version) => { + test("a group stream that arrives after the subscribe stream's FIN is delivered", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const first = groupStream(subscriber, 0); + first.write("0.0"); + first.finish(); + await respond({ end: new SubscribeEnd(2, 2) }); + await fin(); - // The end is known before the last group arrives. - expect(await reader.finished()).toBe(2); + // The end is known before the last group arrives. + expect(await reader.finished()).toBe(2); - // QUIC does not order streams, so group 1 lands after the FIN. - const late = groupStream(subscriber, 1); - late.write("1.0"); - late.finish(); + // QUIC does not order streams, so group 1 lands after the FIN. + const late = groupStream(subscriber, 1); + late.write("1.0"); + late.finish(); - expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); - expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); - expect(await reader.recvGroup()).toBeUndefined(); - expect(await reader.closed).toBeNull(); - expect(reader.final()).toBe(2); -}); + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(2); + }); -test("a group read across the subscribe stream's FIN is delivered whole", async () => { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 0); - group.write("0.0"); + test("a group read across the subscribe stream's FIN is delivered whole", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 0); + group.write("0.0"); - await respond({ end: new SubscribeEnd(1) }); - await fin(); - const received = await reader.recvGroup(); - expect(await received?.readString()).toBe("0.0"); - - // The FIN does not end the group: only its own stream does. - group.write("0.1"); - group.finish(); - expect(await readAll(received)).toEqual(["0.1"]); - expect(await reader.recvGroup()).toBeUndefined(); - expect(await reader.closed).toBeNull(); -}); + await respond({ end: new SubscribeEnd(1, 1) }); + await fin(); + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); -test("a group reset after the subscribe stream's FIN is not presented as complete", async () => { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 0); - group.write("0.0"); - await respond({ end: new SubscribeEnd(1) }); - await fin(); + // The FIN does not end the group: only its own stream does. + group.write("0.1"); + group.finish(); + expect(await readAll(received)).toEqual(["0.1"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + }); - const received = await reader.recvGroup(); - expect(await received?.readString()).toBe("0.0"); - group.reset(); - await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow(); + test("a group reset after the subscribe stream's FIN is not presented as complete", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 0); + group.write("0.0"); + await respond({ end: new SubscribeEnd(1, 1) }); + await fin(); - // The track still ends cleanly: the group was accounted for, as a reset. - expect(await reader.recvGroup()).toBeUndefined(); - expect(await reader.closed).toBeNull(); -}); + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + group.reset(); + await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow(); -test("a group that never arrives is given up on after the subscription's max age", async () => { - const { subscriber, reader, respond, fin } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 1); - group.write("1.0"); - group.finish(); - await respond({ end: new SubscribeEnd(2) }); - await fin(); + // The track still ends cleanly: the group was accounted for, as a reset. + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + }); - // Group 0 was reset before its header arrived, so nothing ever accounts for it. - expect((await reader.recvGroup())?.sequence).toBe(1); - const started = performance.now(); - expect(await reader.recvGroup()).toBeUndefined(); - expect(performance.now() - started).toBeGreaterThanOrEqual(GRACE - 5); - expect(await reader.closed).toBeNull(); - expect(reader.final()).toBe(2); + test("a group that never arrives is given up on after the subscription's max age", async () => { + const { subscriber, reader, respond, fin } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 1); + group.write("1.0"); + group.finish(); + await respond({ end: new SubscribeEnd(2, 2) }); + await fin(); + + // Group 0 was reset before its header arrived, so nothing ever accounts for it. + expect((await reader.recvGroup())?.sequence).toBe(1); + const started = performance.now(); + expect(await reader.recvGroup()).toBeUndefined(); + expect(performance.now() - started).toBeGreaterThanOrEqual(GRACE - 5); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(2); + }); + + test.skipIf(version === Version.DRAFT_07)( + "a subscription ends without waiting once every group is accounted for", + async () => { + // A max age far past the test's patience: only the accounting may end it. + const { subscriber, reader, respond, fin } = await subscribed(version, Milli(60_000)); + await respond({ start: new SubscribeStart(0) }); + await respond({ drop: new SubscribeDrop({ start: 0, end: 0, error: 0 }) }); + const group = groupStream(subscriber, 1); + group.write("1.0"); + group.finish(); + await respond({ end: new SubscribeEnd(2, 2) }); + await fin(); + + expect((await reader.recvGroup())?.sequence).toBe(1); + expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); + expect(await reader.closed).toBeNull(); + }, + ); + + test("a subscription that served nothing ends at SUBSCRIBE_END", async () => { + const { reader, respond, fin } = await subscribed(version, Milli(60_000)); + await respond({ end: new SubscribeEnd(0) }); + await fin(); + + expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(0); + }); + + test("a SUBSCRIBE_END below a group already received aborts the track", async () => { + const { subscriber, reader, respond } = await subscribed(version); + await respond({ start: new SubscribeStart(0) }); + const group = groupStream(subscriber, 3); + group.finish(); + await group.handled; + await respond({ end: new SubscribeEnd(2, 2) }); + + const closed = await reader.closed; + expect(closed).toBeInstanceOf(Error); + }); }); -test("a subscription ends without waiting once every group is accounted for", async () => { - // A max age far past the test's patience: only the accounting may end it. - const { subscriber, reader, respond, fin } = await subscribed(Milli(60_000)); +test("lite-07 ends without grace when every counted stream arrived despite a skipped group", async () => { + const { subscriber, reader, respond, fin } = await subscribed(Version.DRAFT_07, Milli(60_000)); await respond({ start: new SubscribeStart(0) }); - await respond({ drop: new SubscribeDrop({ start: 0, end: 0, error: 0 }) }); - const group = groupStream(subscriber, 1); - group.write("1.0"); - group.finish(); - await respond({ end: new SubscribeEnd(2) }); + for (const sequence of [0, 2]) { + const group = groupStream(subscriber, sequence); + group.write(`${sequence}.0`); + group.finish(); + await group.handled; + } + await respond({ end: new SubscribeEnd(3, 2) }); await fin(); - expect((await reader.recvGroup())?.sequence).toBe(1); + expect((await reader.recvGroup())?.sequence).toBe(0); + expect((await reader.recvGroup())?.sequence).toBe(2); expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); }); -test("a subscription that served nothing ends at SUBSCRIBE_END", async () => { - const { reader, respond, fin } = await subscribed(Milli(60_000)); - await respond({ end: new SubscribeEnd(0) }); +test("lite-07 zero count ends without grace even when the announced range is nonempty", async () => { + const { reader, respond, fin } = await subscribed(Version.DRAFT_07, Milli(60_000)); + await respond({ start: new SubscribeStart(0) }); + await respond({ end: new SubscribeEnd(3, 0) }); await fin(); expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.final()).toBe(0); -}); - -test("a SUBSCRIBE_END below a group already received aborts the track", async () => { - const { subscriber, reader, respond } = await subscribed(); - await respond({ start: new SubscribeStart(0) }); - const group = groupStream(subscriber, 3); - group.finish(); - await group.handled; - await respond({ end: new SubscribeEnd(2) }); - - const closed = await reader.closed; - expect(closed).toBeInstanceOf(Error); + expect(reader.final()).toBe(3); }); 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(); + const { reader, respond, fin } = await subscribed(Version.DRAFT_05); if (started) await respond({ start: new SubscribeStart(0) }); await fin(); expect(await reader.closed).toBeInstanceOf(ProtocolViolation); @@ -231,7 +263,7 @@ for (const started of [false, true]) { } test("a subscribe stream reset preserves the publisher's failure", async () => { - const { reader, reset } = await subscribed(); + const { reader, reset } = await subscribed(Version.DRAFT_05); reset(new StreamError(StreamCode.NotFound)); const closed = await reader.closed; expect(closed).toBeInstanceOf(StreamError); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 88e5c5a80a..7aa4e5ff6a 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1288,7 +1288,7 @@ test("a peer is offered and served the cheapest originated route", async () => { const cheap = origin.dynamic(prefix, { cost: 1n }); const pricey = origin.dynamic(prefix, { cost: 10n }); - expect(wireOf(consumer).advertised.peek()?.get(prefix)?.route).toEqual(Route.normalize({ cost: 1n })); + expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual(Route.normalize({ cost: 1n })); const pending = wireOf(consumer).demand(Path.from("live/cam")); const { value: req } = await cheap.requested().next(); const upstream = new BroadcastProducer(); @@ -1298,9 +1298,9 @@ test("a peer is offered and served the cheapest originated route", async () => { // An exact-path local broadcast competes with them on cost too. const local = origin.createBroadcast(prefix); local.announce({ cost: 5n }); - expect(wireOf(consumer).advertised.peek()?.get(prefix)?.route).toEqual(Route.normalize({ cost: 1n })); + expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual(Route.normalize({ cost: 1n })); local.announce({ cost: 0n }); - expect(wireOf(consumer).advertised.peek()?.get(prefix)?.route).toEqual(Route.normalize({ cost: 0n })); + expect(wireOf(consumer).advertised.peek()?.get(prefix)?.[0]?.route).toEqual(Route.normalize({ cost: 0n })); local.close(); upstream.close(); @@ -1654,8 +1654,82 @@ test("a rooted reader presents the most specific covering route", () => { 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); + expect(wireOf(rooted.consume()).advertised.peek()?.get(Path.empty())?.[0]?.route.cost.warm).toBe(9n); broad.close(); narrow.close(); origin.close(); }); + +test("a scoped reader picks the best route its scope can see at a prefix", async () => { + const origin = new Producer(); + const scoped = (pattern: string) => origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse(pattern)])); + const chat = scoped("*/chat").dynamic(Path.empty(), { cost: 1n }); + const video = scoped("*/video").dynamic(Path.empty(), { cost: 5n }); + const reader = scoped("room/video"); + + expect(reader.broadcasts().peek().get(Path.empty())?.cost.warm).toBe(5n); + expect(origin.broadcasts(Path.Pattern.parse("room/video")).peek().get(Path.empty())?.cost.warm).toBe(5n); + const announced = reader.announced(); + expect((await nextRoute(announced))?.route.cost.warm).toBe(5n); + announced.close(); + + // A session publishing the scoped view offers the video route too. + const offered = [...(wireOf(reader.consume()).advertised.peek()?.get(Path.empty()) ?? [])]; + expect(offered.map((advert) => advert.route.cost.warm)).toEqual([5n]); + + video.close(); + chat.close(); + origin.close(); +}); + +test("a scoped reader sees a local broadcast that only loses to a route outside its scope", () => { + const origin = new Producer(); + const chat = origin + .scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*/chat")])) + .dynamic(Path.from("room/alice"), { cost: 0n }); + const local = origin.createBroadcast(Path.from("room/alice")); + local.announce({ cost: 5n }); + const reader = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/*")])); + + expect(reader.broadcasts().peek().get(Path.from("room/alice"))?.cost.warm).toBe(5n); + // Unscoped, the cheaper route still wins the prefix. + expect(origin.broadcasts().peek().get(Path.from("room/alice"))?.cost.warm).toBe(0n); + + local.close(); + chat.close(); + origin.close(); +}); + +test("broadcast handles carry the path they were created or requested at", async () => { + expect(new BroadcastProducer().consume().path).toBe(Path.empty()); + + 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(broadcast.consume().path).toBe(Path.from("tenant/room/alice")); + + // Relative to each cursor's root, and kept by a clone. + const whole = origin.request(Path.from("tenant/room/alice")); + const rooted = scoped.consume().request(Path.from("room/alice")); + expect(whole.active.peek()?.path).toBe(Path.from("tenant/room/alice")); + expect(rooted.active.peek()?.path).toBe(Path.from("room/alice")); + const clone = rooted.active.peek()?.clone(); + expect(clone?.path).toBe(Path.from("room/alice")); + + // A dynamic handler's standalone broadcast is named by the request, too. + const dynamic = origin.dynamic(Path.from("live")); + const request = origin.request(Path.from("live/bob")); + const pending = await dynamic.requested().next(); + const served = new BroadcastProducer(); + pending.value?.accept(served); + expect(request.active.peek()?.path).toBe(Path.from("live/bob")); + + clone?.close(); + whole.close(); + rooted.close(); + request.close(); + served.close(); + dynamic.close(); + broadcast.close(); + origin.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 0b79cc5ea8..5a54a34eca 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -17,7 +17,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { isAnonymous, Route, routesEqual } from "./hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "./internal.ts"; import * as Path from "./path.ts"; -import { type Advertised, registerWire, wireOf } from "./wire.ts"; +import { type Advertised, type Advertisements, registerWire, wireOf } from "./wire.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; @@ -84,17 +84,39 @@ class Scope { return out; } - /** The advertised prefixes that may serve this scope, relative to its root. */ - projectRoutes( - values: ReadonlyMap | undefined, - ): ReadonlyMap | undefined { + /** + * The advertisements that may serve this scope, relative to its root. Every prefix at or + * above the root presents as the empty path, most specific first, since that is the order + * a request beneath the root resolves in. + */ + projectRoutes(values: Advertisements | undefined): Advertisements | 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); + const out = new Map(); + const covering: [Path.Valid, Advertised[]][] = []; + const allowed = this.allowed && [...this.allowed]; + for (const [path, candidates] of values) { + const relative = Path.stripPrefix(this.root, path); + const above = relative === null || relative === Path.empty(); + if (above && !Path.hasPrefix(path, this.root)) continue; + const visible = candidates + .filter((value) => !allowed || allowed.some((pattern) => advertOverlaps(value, path, pattern))) + // The claim moves with the key, so it compares against root-relative requests. + .map((value) => (value.claim ? { ...value, claim: value.claim.rebase(this.root) } : value)); + if (visible.length === 0) continue; + if (!above) { + out.set(relative, visible); + continue; + } + // Hold the empty path's place in the order until every covering prefix is known. + if (covering.length === 0) out.set(Path.empty(), []); + covering.push([path, visible]); + } + if (covering.length > 0) { + covering.sort(([a], [b]) => b.length - a.length); + out.set( + Path.empty(), + covering.flatMap(([, visible]) => visible), + ); } return out; } @@ -105,11 +127,6 @@ function advertOverlaps(advert: Advertised, prefix: Path.Valid, pattern: Path.Pa 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 @@ -178,11 +195,27 @@ export interface RequestSlot { export interface RouteEntry { readonly identity: object; readonly scope: Scope; + /** The paths the entry may serve beneath its prefix, when its producer is scoped. */ + readonly claim?: Path.Patterns; readonly route: Signal; readonly originated: boolean; readonly server?: ServeState; } +/** One advertisement at a prefix. `exact` marks an announced local broadcast, which is only its own path. */ +interface Candidate extends Advertised { + readonly exact: boolean; +} + +/** Orders advertisements at one prefix: the better route, then a local broadcast on a tie, then fewer hops. */ +function compareCandidates(a: Candidate, b: Candidate): number { + return ( + compareRoutes(a.route, b.route) || + Number(b.exact) - Number(a.exact) || + a.route.hops.length - b.route.hops.length + ); +} + /** Orders two routes by preference: identified before anonymous, then lower warm cost, then lower cold cost. */ function compareRoutes(a: Route, b: Route): number { const anonymous = Number(isAnonymous(a)) - Number(isAnonymous(b)); @@ -341,12 +374,11 @@ class OriginState { routes = new VersionedSignal | undefined>(new Map()); #snapshotVersion = ""; - #snapshot = { - remote: new Map(), - local: new Map(), - routes: new Map(), - visible: new Map(), - }; + #snapshot: { + candidates: ReadonlyMap; + routes: ReadonlyMap; + visible: ReadonlyMap; + } = { candidates: new Map(), routes: new Map(), visible: new Map() }; /** The full route table is built once per mutation, regardless of observer count. */ available = new Derived([this.local, this.advertisedLocal, this.routes], () => this.snapshot().routes); @@ -354,43 +386,57 @@ class OriginState { visible = new Derived([this.local, this.advertisedLocal, this.routes], () => this.snapshot().visible); snapshot(): { - remote: ReadonlyMap; - local: ReadonlyMap; + candidates: ReadonlyMap; routes: ReadonlyMap; visible: ReadonlyMap; } { const version = `${this.local.version}/${this.advertisedLocal.version}/${this.routes.version}`; if (version === this.#snapshotVersion) return this.#snapshot; - const remote = new Map(); - const local = new Map(); + const candidates = this.candidates(); const available = new Map(); - for (const [path, routes] of this.routes.peek() ?? []) { - const entry = preferredEntry(routes); - if (!entry) continue; - const value = { identity: entry.identity, route: entry.route.peek(), claim: claimOf(entry, path) }; - remote.set(path, value); - available.set(path, value.route); - } - for (const [path, front] of this.local.peek() ?? []) { - const routes = this.routes.peek()?.get(path); - if (!this.localWins(path, routes && preferredEntry(routes))) continue; - const value = { identity: front, route: this.advertisedLocal.peek()?.get(path) ?? Route.default }; - local.set(path, value); - available.set(path, value.route); - } const visible = new Map(); - for (const [path, route] of available) { - if (!hiddenBelow(Path.empty(), path)) visible.set(path, route); + for (const [path, [best]] of candidates) { + available.set(path, best.route); + if (!hiddenBelow(Path.empty(), path)) visible.set(path, best.route); } - this.#snapshot = { remote, local, routes: available, visible }; + this.#snapshot = { candidates, routes: available, visible }; this.#snapshotVersion = version; return this.#snapshot; } + /** + * Every advertisement per prefix, most preferred first, without the `skip`ped entries. + * Readers select after filtering by their scope, so a cheaper route they cannot see + * never hides one they can. + */ + candidates(skip?: (entry: RouteEntry) => boolean): Map { + const out = new Map(); + for (const [path, entries] of this.routes.peek() ?? []) { + const list: Candidate[] = []; + for (const entry of entries) { + if (skip?.(entry)) continue; + list.push({ identity: entry.identity, route: entry.route.peek(), claim: entry.claim, exact: false }); + } + if (list.length > 0) out.set(path, list); + } + const advertised = this.advertisedLocal.peek(); + for (const [path, front] of this.local.peek() ?? []) { + const local = { identity: front, route: advertised?.get(path) ?? Route.default, exact: true }; + const list = out.get(path); + if (list) list.push(local); + else out.set(path, [local]); + } + for (const list of out.values()) { + // Stable, so equal routes keep the table's newest-first order. + if (list.length > 1) list.sort(compareCandidates); + } + return out; + } + // Originated advertisements sessions should forward: exact-path announces plus // originated dynamics. Identity is the local front or the route entry, so a // republish diffs as retract-then-announce and a re-price as another active. - originated = new Signal | undefined>(new Map()); + originated = new Signal(new Map()); // Broadcasts materialized from a served route, keyed by exact path. Shared by every // request for the path so repeats reuse one accept; dropped (and closed) when the @@ -474,28 +520,12 @@ class OriginState { /** Rebuild the publisher-facing originated table after an advertisement write. */ rebuildOriginated(): void { - const local = this.local.peek(); - const advertised = this.advertisedLocal.peek(); - const routes = this.routes.peek(); - if (!local && !advertised && !routes) { + if (!this.local.peek() && !this.advertisedLocal.peek() && !this.routes.peek()) { this.originated.set(undefined); return; } - 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(), 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 ?? []) { - const front = local?.get(path); - const entries = routes?.get(path); - if (front && this.localWins(path, entries && preferredEntry(entries, received))) { - next.set(path, { identity: front, route }); - } - } - this.originated.set(next); + this.originated.set(this.candidates(received)); } /** @@ -708,6 +738,7 @@ export class Producer implements Table { if (!created) throw new Error("origin is closed"); const producer = new broadcast.Producer(); + hooks.stampPath(producer, path); const front = producer.consume(); hooks.attachAnnouncer(producer, { @@ -796,6 +827,7 @@ export class Producer implements Table { const entry: RouteEntry = { identity: {}, scope: this.#scope, + claim: this.#scope.allowed?.intersect(new Path.Patterns([Path.Pattern.subtree(prefix)])), route: new Signal(route), originated, server, @@ -1279,6 +1311,7 @@ export class Consumer { const previous = handle; source = front; handle = front?.clone(); + if (handle) hooks.stampPath(handle, relative); previous?.close(); } return handle; @@ -1372,29 +1405,32 @@ export class Consumer { #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 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( + for (const [path, candidates] of this.#state.snapshot().candidates) { + // The first candidate this reader can see wins, since the preferred one overall may not be. + let entry: Candidate | undefined; + let scope: (typeof scopes)[number] | undefined; + for (const candidate of candidates) { + scope = scopes.find( ({ pattern, head }) => - (exact ? pattern.matches(path) : advertOverlaps(entry, path, pattern)) && + (candidate.exact ? pattern.matches(path) : advertOverlaps(candidate, 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), - }); + if (scope) { + entry = candidate; + break; + } } + if (!entry || !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; } diff --git a/js/net/src/stream.test.ts b/js/net/src/stream.test.ts index 8a4dc2773f..8ecdede0ca 100644 --- a/js/net/src/stream.test.ts +++ b/js/net/src/stream.test.ts @@ -11,7 +11,7 @@ import { StreamError, } from "./error.ts"; import { Version } from "./ietf/version.ts"; -import { Reader, Stream, Writer } from "./stream.ts"; +import { type Cursor, Reader, Stream, Writer } from "./stream.ts"; import { TimeoutError } from "./util/timeout.ts"; // Helper to create a writable stream that captures written data @@ -238,22 +238,18 @@ test("Reader u53 rejects integers that cannot be represented exactly", async () const wireValues = [firstUnsafe, firstUnsafe + 1n]; expect(Number(wireValues[0])).toBe(Number(wireValues[1])); - const { stream, written } = createTestWritableStream(); - const writer = new Writer(stream); - for (const value of wireValues) { + const { stream, written } = createTestWritableStream(); + const writer = new Writer(stream); await writer.u62(value); - } - - writer.close(); - await writer.closed; + writer.close(); + await writer.closed; - const reader = new Reader(undefined, concatChunks(written)); - for (const value of wireValues) { + // A failed decode consumes nothing; the stream is unusable after it anyway. + const reader = new Reader(undefined, concatChunks(written)); await expect(reader.u53()).rejects.toThrow(`value larger than 53-bits: ${value}`); + expect(await reader.done()).toBe(false); } - - expect(await reader.done()).toBe(true); }); test("Reader u62 varint decoding", async () => { @@ -350,7 +346,7 @@ test("Reader stream with partial reads", async () => { expect(await reader.done()).toBe(true); }); -test("Reader owns streamed chunks and preserves returned views across fills", async () => { +test("Reader preserves returned views across fills", async () => { const first = new Uint8Array([99, 1, 2, 99]); const second = new Uint8Array([99, 3, 4, 5, 99]); const stream = new ReadableStream({ @@ -362,10 +358,8 @@ test("Reader owns streamed chunks and preserves returned views across fills", as }); const reader = new Reader(stream); const head = await reader.read(1); - first.fill(0); expect(head).toEqual(new Uint8Array([1])); const joined = await reader.read(3); - second.fill(0); expect(joined).toEqual(new Uint8Array([2, 3, 4])); joined.fill(0); expect(head).toEqual(new Uint8Array([1])); @@ -373,6 +367,36 @@ test("Reader owns streamed chunks and preserves returned views across fills", as expect(await reader.done()).toBe(true); }); +test("Reader returns a view of a chunk that already holds the read", async () => { + const chunk = new Uint8Array([1, 2, 3, 4]); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.close(); + }, + }); + const reader = new Reader(stream); + const read = await reader.read(3); + expect(read.buffer).toBe(chunk.buffer); + expect(read).toEqual(new Uint8Array([1, 2, 3])); + expect(await reader.readAll()).toEqual(new Uint8Array([4])); +}); + +test("Reader joins every chunk a read spans", async () => { + const stream = new ReadableStream({ + start(controller) { + for (let value = 0; value < 100; value++) controller.enqueue(new Uint8Array([value])); + controller.close(); + }, + }); + const reader = new Reader(stream); + expect(await reader.u8()).toBe(0); + expect(await reader.read(98)).toEqual(Uint8Array.from({ length: 98 }, (_, index) => index + 1)); + expect(await reader.done()).toBe(false); + expect(await reader.readAll()).toEqual(new Uint8Array([99])); + expect(await reader.done()).toBe(true); +}); + test("Reader u53 decodes two-byte stream type prefixes", async () => { const { stream, written } = createTestWritableStream(); const writer = new Writer(stream); @@ -386,6 +410,54 @@ test("Reader u53 decodes two-byte stream type prefixes", async () => { expect(await reader.done()).toBe(true); }); +/** A length-prefixed payload. */ +const sized = (c: Cursor) => c.read(c.u53()); + +test("Reader tryDecode drains every buffered message, then consumes nothing from a partial one", async () => { + let controller!: ReadableStreamDefaultController; + const reader = new Reader(new ReadableStream({ start: (c) => (controller = c) })); + controller.enqueue(new Uint8Array([1, 0xa, 2, 0xb, 0xc, 3, 0xd])); + expect(await reader.done()).toBe(false); + + expect(reader.tryDecode(sized)).toEqual(new Uint8Array([0xa])); + expect(reader.tryDecode(sized)).toEqual(new Uint8Array([0xb, 0xc])); + expect(reader.tryDecode(sized)).toBeUndefined(); + expect(reader.tryDecode(sized)).toBeUndefined(); + + const pending = reader.decode(sized); + controller.enqueue(new Uint8Array([0xe])); + controller.enqueue(new Uint8Array([0xf])); + expect(await pending).toEqual(new Uint8Array([0xd, 0xe, 0xf])); + controller.close(); + expect(await reader.decodeMaybe(sized)).toBeUndefined(); +}); + +test("Reader tryDecode holds only the decode that ran short to the bytes it needs", () => { + const reader = new Reader(undefined, new Uint8Array([2, 0xa])); + expect(reader.tryDecode(sized)).toBeUndefined(); + expect(reader.tryDecode((c) => c.u8())).toBe(2); +}); + +test("Reader decode rejects a stream that ends inside a message", async () => { + const reader = new Reader(undefined, new Uint8Array([3, 0xa])); + expect(reader.tryDecode(sized)).toBeUndefined(); + await expect(reader.decode(sized)).rejects.toThrow("unexpected end of stream"); +}); + +test("Reader refuses an oversized value even when it is already buffered", async () => { + const size = 64 * 1024 * 1024 + 1; + const buffer = new Uint8Array(4 + size); + buffer.set([0x84, 0x00, 0x00, 0x01]); // the 4-byte varint for size + await expect(new Reader(undefined, buffer).string()).rejects.toThrow("exceeds max size"); + await expect(new Reader(undefined, buffer.subarray(4)).read(size)).rejects.toThrow("exceeds max size"); +}); + +test("Reader refuses a buffered decode whose fields together exceed the max size", async () => { + const half = 32 * 1024 * 1024; + const reader = new Reader(undefined, new Uint8Array(2 * half + 1)); + await expect(reader.decode((c) => [c.read(half), c.read(half + 1)])).rejects.toThrow("exceeds max size"); +}); + /** A stream reset as a transport delivers one: the peer's code, and nothing else useful. */ class Reset extends Error { readonly source = "stream" as const; diff --git a/js/net/src/stream.ts b/js/net/src/stream.ts index eaf3e8ab6d..8093e4495c 100644 --- a/js/net/src/stream.ts +++ b/js/net/src/stream.ts @@ -180,10 +180,16 @@ export class Stream { // Reader wraps a stream and provides convience methods for reading pieces from a stream // Unfortunately we can't use a BYOB reader because it's not supported with WebTransport+WebWorkers yet. export class Reader { + // Contiguous unread bytes, followed by chunks not yet joined onto it. Joining only once a + // read needs the bytes keeps a frame arriving in N chunks linear rather than quadratic. #buffer: Uint8Array; + #chunks: Uint8Array[] = []; + #chunked = 0; // bytes across #chunks #stream?: ReadableStream; // if undefined, the buffer is consumed then EOF #reader?: ReadableStreamDefaultReader; #closed?: Promise; + // The decode that last ran short and how far, so a retry can wait for those bytes. + #short?: { decode: (c: Cursor) => unknown; err: Short }; version?: IetfVersion; // Either stream or buffer MUST be provided. @@ -216,16 +222,8 @@ export class Reader { throw new Error("unexpected empty chunk"); } - const buffer = result.value; - - if (this.#buffer.byteLength === 0) { - this.#buffer = new Uint8Array(buffer); - } else { - const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength); - temp.set(this.#buffer); - temp.set(buffer, this.#buffer.byteLength); - this.#buffer = temp; - } + this.#chunks.push(result.value); + this.#chunked += result.value.byteLength; return true; } @@ -236,11 +234,36 @@ export class Reader { throw new Error(`read size ${size} exceeds max size ${MAX_READ_SIZE}`); } - while (this.#buffer.byteLength < size) { + if (this.#buffer.byteLength >= size) return; + + while (this.#buffer.byteLength + this.#chunked < size) { if (!(await this.#fill())) { throw new Error("unexpected end of stream"); } } + + this.#join(); + } + + // Move every pending chunk into the buffer, copying only when there's more than one piece. + #join() { + if (this.#chunks.length === 0) return; + + if (this.#buffer.byteLength === 0 && this.#chunks.length === 1) { + this.#buffer = this.#chunks[0]; + } else { + const joined = new Uint8Array(this.#buffer.byteLength + this.#chunked); + joined.set(this.#buffer); + let offset = this.#buffer.byteLength; + for (const chunk of this.#chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + this.#buffer = joined; + } + + this.#chunks = []; + this.#chunked = 0; } // Consumes the first size bytes of the buffer. @@ -255,96 +278,254 @@ export class Reader { return result; } + /** + * Run a synchronous decode over the buffered bytes and consume what it read. + * + * Returns undefined and consumes nothing when the decode ran past the buffered bytes, so a + * caller can drain every complete message already here without waiting on the stream. + */ + tryDecode>(decode: (c: Cursor) => T): T | undefined { + const result = this.#try(decode); + return result instanceof Short ? undefined : result; + } + + /** Run a synchronous decode, filling from the stream until it has the bytes it needs. */ + async decode(decode: (c: Cursor) => T): Promise { + for (;;) { + const result = this.#try(decode); + if (!(result instanceof Short)) return result; + await this.#fillTo(result.need); + } + } + + /** Like {@link decode}, but returns undefined if the stream ends cleanly first. */ + async decodeMaybe(decode: (c: Cursor) => T): Promise { + if (await this.done()) return undefined; + return this.decode(decode); + } + + #try(decode: (c: Cursor) => T): T | Short { + // A retry of the decode that last ran short, before the bytes it needs have arrived, + // would only throw again. Every decode reads at least a byte, so none can succeed on + // an empty buffer either. + const available = this.#buffer.byteLength + this.#chunked; + if (available === 0) return EMPTY; + if (decode === this.#short?.decode && available < this.#short.err.need) return this.#short.err; + + this.#join(); + const cursor = new Cursor(this.#buffer, this.version); + try { + const result = decode(cursor); + this.#slice(cursor.offset); + this.#short = undefined; + return result; + } catch (err: unknown) { + if (!(err instanceof Short)) throw err; + // Filling could never satisfy it, so retrying would spin. + if (err.need <= this.#buffer.byteLength) throw new Error("decode ran short of bytes it already had"); + this.#short = { decode, err }; + return err; + } + } + async read(size: number): Promise { if (size === 0) return new Uint8Array(); - - await this.#fillTo(size); - return this.#slice(size); + return this.decode((c) => c.read(size)); } async readAll(): Promise { while (await this.#fill()) { // keep going } + this.#join(); return this.#slice(this.#buffer.byteLength); } async string(): Promise { - const length = await this.u53(); - const buffer = await this.read(length); - return decodeUtf8(buffer); + return this.decode(STRING); } async bool(): Promise { - const v = await this.u8(); - if (v === 0) return false; - if (v === 1) return true; - throw new Error("invalid bool value"); + return this.decode(BOOL); } async u8(): Promise { - await this.#fillTo(1); - return this.#slice(1)[0]; + return this.decode(U8); } async u16(): Promise { - await this.#fillTo(2); - const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, 2); - const result = view.getUint16(0); - this.#slice(2); - return result; + return this.decode(U16); } // Returns a Number using 53-bits, the max Javascript can use for integer math. async u53(): Promise { - const v = await this.u62(); - if (v > Varint.MAX_U53) { - throw new Error(`value larger than 53-bits: ${v.toString()}`); - } - - return Number(v); + return this.decode(U53); } // NOTE: Returns a bigint instead of a number since it may be larger than 53-bits async u62(): Promise { - if (isLeadingOnes(this.version)) { - return this.#readLeadingOnes(); - } - return this.#readQuicVarint(); + return this.decode(U62); + } + + // Returns false if there is more data to read, blocking if it hasn't been received yet. + async done(): Promise { + if (this.#buffer.byteLength > 0 || this.#chunked > 0) return false; + return !(await this.#fill()); + } + + stop(reason: unknown) { + this.#reader?.cancel(withCode(reason, this.version)).catch(() => void 0); + } + + // Decoded like #fill: a caller racing this against a read must not get a different error + // shape depending on which one won. Derived once, so racing it per frame doesn't allocate. + get closed(): Promise { + this.#closed ??= (this.#reader?.closed ?? Promise.resolve()).catch((err: unknown) => { + throw fromTransport(err, { version: this.version }); + }); + return this.#closed; + } +} + +// Thrown by a Cursor read that runs past the buffered bytes, carrying how many bytes from the +// start of the buffer the decode needs. Not an Error: it ends every chunk, so it must not +// capture a stack. +class Short { + readonly need: number; + + constructor(need: number) { + this.need = need; + } +} + +const EMPTY = new Short(1); + +/** + * A synchronous view over a {@link Reader}'s buffered bytes, handed to {@link Reader.decode}. + * + * A read past the buffered bytes throws an internal signal that the Reader catches: it consumes + * nothing, fills, and runs the decode again from the start. A decode must therefore not mutate + * anything before its last read, must not swallow what it throws, and must read at least a byte. + */ +export class Cursor { + readonly version?: IetfVersion; + #buffer: Uint8Array; + #offset = 0; + + constructor(buffer: Uint8Array, version?: IetfVersion) { + this.#buffer = buffer; + this.version = version; } - async #readQuicVarint(): Promise { - await this.#fillTo(1); - const size = (this.#buffer[0] & 0xc0) >> 6; + /** How many bytes have been read. */ + get offset(): number { + return this.#offset; + } - if (size === 0) { - const first = this.#slice(1)[0]; - return BigInt(first) & 0x3fn; + /** How many buffered bytes are left to read. */ + get remaining(): number { + return this.#buffer.byteLength - this.#offset; + } + + /** + * Decode the next `size` bytes on their own. Running past them, or leaving any unread, is + * malformed rather than a reason to wait for more. + */ + exact(size: number, decode: (c: Cursor) => T): T { + const inner = new Cursor(this.read(size), this.version); + let result: T; + try { + result = decode(inner); + } catch (err: unknown) { + if (err instanceof Short) throw new Error(`message is shorter than its fields: ${size} bytes`); + throw err; } - if (size === 1) { - await this.#fillTo(2); - const slice = this.#slice(2); - const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); + if (inner.remaining > 0) throw new Error(`message has ${inner.remaining} unread bytes`); + return result; + } + + #ensure(size: number) { + const need = this.#offset + size; + // Checked here too, and on the whole decode like the fill, since bytes that are already + // buffered never reach the fill. + if (need > MAX_READ_SIZE) throw new Error(`read size ${need} exceeds max size ${MAX_READ_SIZE}`); + if (need > this.#buffer.byteLength) throw new Short(need); + } + + /** Read `size` bytes, as a view onto the buffer rather than a copy. */ + read(size: number): Uint8Array { + this.#ensure(size); + const start = this.#offset; + this.#offset += size; + return this.#buffer.subarray(start, this.#offset); + } + + string(): string { + return decodeUtf8(this.read(this.u53())); + } + + bool(): boolean { + const v = this.u8(); + if (v === 0) return false; + if (v === 1) return true; + throw new Error("invalid bool value"); + } + + u8(): number { + this.#ensure(1); + return this.#buffer[this.#offset++]; + } + + u16(): number { + this.#ensure(2); + const b = this.#buffer; + const o = this.#offset; + this.#offset += 2; + return (b[o] << 8) | b[o + 1]; + } - return BigInt(view.getUint16(0)) & 0x3fffn; + // Returns a Number using 53-bits, the max Javascript can use for integer math. + u53(): number { + // Most varints fit in 4 bytes, which decode without a bigint. + if (!isLeadingOnes(this.version)) { + this.#ensure(1); + const b = this.#buffer; + const o = this.#offset; + const size = 1 << (b[o] >> 6); + if (size < 8) { + this.#ensure(size); + this.#offset += size; + if (size === 1) return b[o] & 0x3f; + if (size === 2) return ((b[o] & 0x3f) << 8) | b[o + 1]; + return (b[o] & 0x3f) * 2 ** 24 + ((b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]); + } } - if (size === 2) { - await this.#fillTo(4); - const slice = this.#slice(4); - const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); - return BigInt(view.getUint32(0)) & 0x3fffffffn; + const v = this.u62(); + if (v > Varint.MAX_U53) { + throw new Error(`value larger than 53-bits: ${v.toString()}`); } - await this.#fillTo(8); - const slice = this.#slice(8); - const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); + return Number(v); + } + + // NOTE: Returns a bigint instead of a number since it may be larger than 53-bits + u62(): bigint { + return isLeadingOnes(this.version) ? this.#leadingOnes() : this.#quicVarint(); + } + #quicVarint(): bigint { + this.#ensure(1); + const size = 1 << (this.#buffer[this.#offset] >> 6); + if (size < 8) return BigInt(this.u53()); + + const slice = this.read(8); + const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); return view.getBigUint64(0) & 0x3fffffffffffffffn; } - async #readLeadingOnes(): Promise { - await this.#fillTo(1); - const b = this.#buffer[0]; + #leadingOnes(): bigint { + this.#ensure(1); + const b = this.#buffer[this.#offset]; // Count leading 1-bits let ones = 0; @@ -364,33 +545,19 @@ export class Reader { else if (ones === 7) totalSize = 8; else totalSize = 9; // ones === 8 - await this.#fillTo(totalSize); - const slice = this.#slice(totalSize); - - const [value] = Varint.decodeLeadingOnes(slice); + const [value] = Varint.decodeLeadingOnes(this.read(totalSize)); return value; } - - // Returns false if there is more data to read, blocking if it hasn't been received yet. - async done(): Promise { - if (this.#buffer.byteLength > 0) return false; - return !(await this.#fill()); - } - - stop(reason: unknown) { - this.#reader?.cancel(withCode(reason, this.version)).catch(() => void 0); - } - - // Decoded like #fill: a caller racing this against a read must not get a different error - // shape depending on which one won. Derived once, so racing it per frame doesn't allocate. - get closed(): Promise { - this.#closed ??= (this.#reader?.closed ?? Promise.resolve()).catch((err: unknown) => { - throw fromTransport(err, { version: this.version }); - }); - return this.#closed; - } } +// Shared decodes for the Reader's async primitives, so a read allocates no closure. +const STRING = (c: Cursor) => c.string(); +const BOOL = (c: Cursor) => c.bool(); +const U8 = (c: Cursor) => c.u8(); +const U16 = (c: Cursor) => c.u16(); +const U53 = (c: Cursor) => c.u53(); +const U62 = (c: Cursor) => c.u62(); + // Writer wraps a stream and writes chunks of data export class Writer { #writer: WritableStreamDefaultWriter; diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 2510ff62a0..a0faf2bcec 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -453,6 +453,29 @@ test("an unstamped immediate successor leaves reach unbounded", async () => { expect((await track.recvGroup())?.sequence).toBe(2); }); +// Groups can arrive out of sequence order; the immediate successor bounds reach even when it +// arrived last. +test("a late-arriving successor bounds a group's reach", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(5000) }); + const track = producer.subscribe({ maxAge: Milli(500) }); + + for (const [sequence, ms] of [ + [0, 0], + [2, 3000], + [1, 200], + ]) { + const group = new GroupProducer(sequence); + group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); + group.close(); + producer.writeGroup(group); + } + + // Group 0 reaches at most 200ms, where group 1 starts, so it is past the budget. + // Group 1 reaches 3000ms, the edge itself. + expect((await track.recvGroup())?.sequence).toBe(1); + expect((await track.recvGroup())?.sequence).toBe(2); +}); + // The ordered frame helpers ride the same cursor, so they see the same budget: a // backlog inside it is drained in full, and what is past it is skipped. test("ordered frame reads follow the budget", async () => { @@ -807,7 +830,7 @@ test("a handed-out frame cancels its in-flight operation when it expires", async const operation = new Promise((resolve) => { release = resolve; }); - const guarded = hooks.guardGroup(group, operation); + const guarded = hooks.guardGroup(group, () => operation); producer.writeString("new"); await expect(guarded).rejects.toThrow("max age budget"); @@ -830,7 +853,7 @@ test("a guarded write keeps the position of the frame removed from the buffer", const operation = new Promise((resolve) => { release = resolve; }); - const guarded = hooks.guardGroup(group, operation); + const guarded = hooks.guardGroup(group, () => operation); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(1_000) }); // A group beyond the edge, so group 0's reach (1s) is provably behind it: a group is @@ -859,7 +882,7 @@ test("clean source closure stays provisional while a frame write can expire", as const operation = new Promise((resolve) => { release = resolve; }); - const guarded = hooks.guardGroup(group, operation); + const guarded = hooks.guardGroup(group, () => operation); const edge = producer.appendGroup(); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(1_000) }); @@ -963,6 +986,36 @@ test("retention reclaims a group the publisher abandoned open", async () => { } }); +test("an idle live edge ages out once a newer group arrives", async () => { + const clock = mockMonotonicTime(10_000); + try { + const producer = new TrackProducer("test").accept({ maxAge: Milli(100) }); + const track = producer.subscribe({ maxAge: Milli(100) }); + const edge = producer.appendGroup(); + edge.writeString("first"); + + const group = await track.recvGroup(); + if (!group) throw new Error("missing group"); + expect(await group.readString()).toBe("first"); + + // A prune while it is still the live edge keeps it, and finds nothing else to age out. + clock.set(10_200); + producer.subscribe({ maxAge: Milli(100) }); + + // A successor ends the exemption, and the group is long past the window, so the + // write evicts it rather than a later wakeup. + clock.set(10_300); + producer.appendGroup(); + const read = group.readFrame().then( + () => "clean end", + (err: unknown) => err, + ); + expect(await Promise.race([read, settle().then(() => "still parked")])).toBeInstanceOf(TooFarBehind); + } finally { + clock.restore(); + } +}); + test("an abandoned open group ages out with no further write", async () => { // Real time, since this is about the wakeup: nothing writes to the track again, so // without a timer the read below parks forever. @@ -1659,3 +1712,113 @@ test("an omitted publisher limit retains old groups", async () => { clock.restore(); } }); + +test("an abort keeps finished groups for a slow reader, then reports it", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(10_000) }); + const arrival = producer.subscribe({ maxAge: Milli(10_000) }); + const ordered = producer.subscribe({ maxAge: Milli(10_000) }).ordered(); + for (let i = 0; i < 2; i++) { + const group = producer.appendGroup(); + group.writeString(`g${i}`); + group.close(); + } + producer.appendGroup().writeString("open"); + const boom = new Error("boom"); + producer.close(boom); + + for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { + for (let i = 0; i < 2; i++) { + const group = await next(); + expect(group?.sequence).toBe(i); + expect(await group?.readString()).toBe(`g${i}`); + } + // The open group nobody will finish is not handed out. + await expect(next()).rejects.toBe(boom); + } +}); + +test("an abort after the declared end settles ends clean", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(10_000) }); + const arrival = producer.subscribe({ maxAge: Milli(10_000) }); + const ordered = producer.subscribe({ maxAge: Milli(10_000) }).ordered(); + for (let i = 0; i < 2; i++) { + const group = producer.appendGroup(); + group.writeString(`g${i}`); + group.close(); + } + producer.finishAt(2); + producer.close(new Error("boom")); + + for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { + expect((await next())?.sequence).toBe(0); + expect((await next())?.sequence).toBe(1); + expect(await next()).toBeUndefined(); + } +}); + +test("an abort after the declared end reached by a datagram ends clean", () => { + const producer = new TrackProducer("test").accept(); + producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("d")); + producer.finishAt(1); + producer.close(new Error("boom")); + expect(producer.closed.peek()).toBeNull(); +}); + +test("an ended track's buffered groups age out for a stale subscriber", () => { + // Nothing writes after the close, so only the prune wakeup can reclaim them. Stub the + // timers so the test fires exactly the wakeups still armed, at a mocked time. + const clock = mockMonotonicTime(10_000); + const realSet = globalThis.setTimeout; + const realClear = globalThis.clearTimeout; + const armed = new Map void>(); + // @ts-expect-error a stub, not a full setTimeout + globalThis.setTimeout = (fn: () => void) => { + const handle = { unref: () => {} }; + armed.set(handle, fn); + return handle; + }; + // @ts-expect-error a stub, not a full clearTimeout + globalThis.clearTimeout = (handle: object) => armed.delete(handle); + + try { + for (const abort of [undefined, new Error("boom")]) { + clock.set(10_000); + armed.clear(); + const producer = new TrackProducer("test").accept({ maxAge: Milli(30) }); + const stale = producer.subscribe({ maxAge: Milli(30) }); + const group = producer.appendGroup(); + group.writeString("x"); + group.close(); + producer.close(abort); + + clock.set(10_100); + for (const [handle, fire] of [...armed]) { + armed.delete(handle); + fire(); + } + // Nothing buffered is left: the subscriber sees only how the track ended. + if (abort) expect(() => stale.tryRecvGroup()).toThrow(abort); + else expect(stale.tryRecvGroup()).toBeUndefined(); + } + } finally { + globalThis.setTimeout = realSet; + globalThis.clearTimeout = realClear; + clock.restore(); + } +}); + +test("an abort leaves a group already taken readable, then reports the abort", async () => { + const producer = new TrackProducer("test").accept({ maxAge: Milli(10_000) }); + const arrival = producer.subscribe({ maxAge: Milli(10_000) }); + const ordered = producer.subscribe({ maxAge: Milli(10_000) }).ordered(); + producer.appendGroup().writeString("held"); + const held = [await arrival.recvGroup(), await ordered.nextGroup()]; + const boom = new Error("boom"); + producer.close(boom); + + for (const group of held) { + expect(group?.sequence).toBe(0); + expect(await group?.readString()).toBe("held"); + await expect(group?.readFrame()).rejects.toBe(boom); + } +}); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index b987d1907c..d1b476bbf1 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -24,6 +24,9 @@ export type { Datagram } from "./datagram.ts"; // and fires right away. const MAX_TIMEOUT_MS = 2 ** 31 - 1; +// The cache scans at most this many times per retention window. +const PRUNE_SLICES = 8; + /** Maximum buffered datagrams per subscriber; mirrors Rust's bounded send buffer. */ const MAX_DATAGRAMS = 64; @@ -283,6 +286,31 @@ export class Consumer { } } +// The index of the first timeline group at or after `sequence`. +function timelineIndex(timeline: GroupConsumer[], sequence: number): number { + let lo = 0; + let hi = timeline.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (timeline[mid].sequence < sequence) lo = mid + 1; + else hi = mid; + } + return lo; +} + +// Add a group to a sorted timeline, replacing any group with the same sequence. Groups +// usually arrive in order, so the append is checked first. +function timelineInsert(timeline: GroupConsumer[], group: GroupConsumer): void { + const last = timeline.at(-1); + if (!last || last.sequence < group.sequence) { + timeline.push(group); + return; + } + const index = timelineIndex(timeline, group.sequence); + if (timeline[index]?.sequence === group.sequence) timeline[index] = group; + else timeline.splice(index, 0, group); +} + // The shared state behind a Producer / Subscriber pair. Package-internal // wiring, unexported so it never appears in the published type declarations. class TrackState { @@ -290,9 +318,10 @@ class TrackState { producer?: Producer; groups = new Signal([]); // Every group still in the producer's replay cache, including groups this - // subscriber already consumed, paired with its source queue time. Drift anchors - // have the same lifetime as content. - timeline = new Map(); + // subscriber already consumed, sorted by sequence. Drift anchors have the same + // lifetime as content. Sorted so the latency guard, evaluated per group and per + // arrival, searches it instead of scanning the whole retained window. + timeline: GroupConsumer[] = []; // First timestamps mutate group state rather than track state, so held groups // watch this revision as well as arrivals when enforcing latency after handoff. timelineChanged = new Signal(0); @@ -344,7 +373,7 @@ async function resolveInfo(state: TrackState): Promise { // A source group retained in the producer cache, with the mirror handed to each sink // so eviction can drop them together. -type CachedGroup = { group: GroupProducer; time: number; mirrors: Map }; +type CachedGroup = { group: GroupProducer; mirrors: Map }; function bindProducer(name: string, producer: Producer, sequences: TrackSequences): void { let shared = sequences.get(name); @@ -395,17 +424,26 @@ export class Producer { // read mirrored sinks, never this state directly. #state = new TrackState(); #sequence: TrackSequence = { next: 0 }; + // One past the highest group or datagram this producer received, like the Rust + // `max_sequence`. The shared counter above can run ahead of it: sibling producers of + // the same track advance it too. + #received = 0; // Recently written source groups, retained for replay to late subscribers and // pruned once idle for longer than the cache window. Each entry tracks the mirror // it handed to every sink so eviction can drop them too: otherwise a slow consumer // that never reads would pin old groups (and their frame bytes) forever. #cache: CachedGroup[] = []; + // The same entries by sequence, so a write finds a duplicate without a scan. + #cached = new Map(); + // When the cache was last scanned. See #prune. + #pruned = Number.NEGATIVE_INFINITY; // Wakeup for the next entry due to age out. Writes settle retention inline, but a // publisher that stalls stops writing, so without this an abandoned group (and any // reader parked in it) would wait for a write that never comes. #pruneTimer?: ReturnType; + #pruneTimerAt = 0; // One independent downstream state per live subscriber. #sinks = new Set(); @@ -529,6 +567,19 @@ export class Producer { forward(); this.#sinks.delete(sink); this.#updateSubscription(); + // Update demand: once the last subscriber leaves, the consumer wire (watching + // {@link unused}) tears the upstream down instead of downloading to nobody. + this.#used.set(this.#sinks.size > 0); + // The producer closing every sink leaves the sink's buffered mirrors readable. + // Bounded retention keeps them tracked so they age out with the cache; + // unlimited retention never ages anything out, so it leaves them to the reader. + if (this.#state.closed.peek() !== undefined) { + if (this.#state.info.peek()?.maxAge === undefined) { + for (const entry of this.#cache) entry.mirrors.delete(sink); + } + dispose(); + return; + } for (const entry of this.#cache) { const mirror = entry.mirrors.get(sink); if (mirror) { @@ -538,10 +589,6 @@ export class Producer { } for (const group of sink.groups.peek()) group.close(abort); dispose(); - - // Update demand: once the last subscriber leaves, the consumer wire (watching - // {@link unused}) tears the upstream down instead of downloading to nobody. - this.#used.set(this.#sinks.size > 0); }); } @@ -567,7 +614,7 @@ export class Producer { #mirror(entry: CachedGroup, sink: TrackState): void { const dst = entry.group.mirror(); entry.mirrors.set(sink, dst); - sink.timeline.set(dst.sequence, { group: dst, time: entry.time }); + timelineInsert(sink.timeline, dst); void dst.readable().then(() => sink.timelineChanged.update((revision) => revision + 1)); sink.latest = Math.max(sink.latest ?? 0, dst.sequence); sink.groups.mutate((groups) => { @@ -583,18 +630,32 @@ export class Producer { // drained it. The usual case, an already-closed group aging out, keeps its own // terminal state. if (!entry.group.isClosed) entry.group.close(new TooFarBehind()); + const mirrors = [...entry.mirrors.values()]; + for (const mirror of mirrors) hooks.evictGroup(mirror); + this.#unlink(entry); + for (const mirror of mirrors) mirror.close(); + } + + // Take a cached group's mirrors out of every sink, so a subscriber can no longer + // receive them. A reader already holding one keeps it as is. + #unlink(entry: CachedGroup): void { for (const [sink, mirror] of entry.mirrors) { - hooks.evictGroup(mirror); sink.groups.mutate((groups) => { const i = groups.indexOf(mirror); if (i >= 0) groups.splice(i, 1); }); - if (sink.timeline.get(mirror.sequence)?.group === mirror) sink.timeline.delete(mirror.sequence); - mirror.close(); + const index = timelineIndex(sink.timeline, mirror.sequence); + if (sink.timeline[index] === mirror) sink.timeline.splice(index, 1); } entry.mirrors.clear(); } + // Take a cached group out of the cache lookups. + #uncache(entry: CachedGroup): void { + this.#cache.splice(this.#cache.indexOf(entry), 1); + this.#cached.delete(entry.group.sequence); + } + // The one group retention never takes: the newest, while it is still open. That is // the live edge a publisher is appending to, and a track may legitimately keep it // open across a long quiet stretch (a catalog snapshot, a JSON stream). Every other @@ -607,48 +668,58 @@ export class Producer { // Evict cached groups idle for longer than the cache window. Idle means nothing // written, so an abandoned open group ages out instead of pinning its buffer (and // any reader parked in it) forever. + // + // Scans at most once per slice of the window, so a track publishing faster than that + // evicts a run of groups per scan instead of scanning everything to evict one per + // write. A group can outlive the window by up to one slice. #prune(): void { const maxAgeMs = this.#state.info.peek()?.maxAge; if (maxAgeMs === undefined) return; - const cutoff = performance.now() - maxAgeMs; + const now = performance.now(); + const slice = maxAgeMs / PRUNE_SLICES; + if (now < this.#pruned + slice) { + // Something may have come due since the last scan, so make sure another follows. + this.#wake(this.#pruned + slice); + return; + } + this.#pruned = now; + + const cutoff = now - maxAgeMs; const live = this.#liveEdge(); + let oldest: number | undefined; const retained: CachedGroup[] = []; for (const entry of this.#cache) { - if (entry.group === live || entry.group.activity >= cutoff) { + if (entry.group === live) { retained.push(entry); - continue; + } else if (entry.group.activity >= cutoff) { + retained.push(entry); + if (oldest === undefined || entry.group.activity < oldest) oldest = entry.group.activity; + } else { + this.#cached.delete(entry.group.sequence); + this.#evict(entry); } - this.#evict(entry); } this.#cache = retained; - this.#schedulePrune(); - } - // Arm the wakeup for the next entry due to age out, replacing any pending one. - // Writes settle retention inline, so this only has to cover the case no write - // follows. Cheap to over-arm: an entry written since is retained and re-armed. - #schedulePrune(): void { + // Replace the wakeup with one for the next entry due to age out. Writes settle + // retention inline, so this only has to cover the case no write follows. Cheap to + // over-arm: an entry written since is retained and re-armed. clearTimeout(this.#pruneTimer); this.#pruneTimer = undefined; - if (this.#state.closed.peek() !== undefined) return; + if (oldest !== undefined) this.#wake(Math.max(oldest + maxAgeMs, now + slice)); + } - // One pass, no intermediate arrays: this runs on every publish, and a spread - // over the cache would also cap how many groups a track can hold. - const live = this.#liveEdge(); - let oldest: number | undefined; - for (const entry of this.#cache) { - if (entry.group === live) continue; - if (oldest === undefined || entry.group.activity < oldest) oldest = entry.group.activity; - } - if (oldest === undefined) return; + // Arm the prune wakeup for `at`, unless one is already armed sooner. Kept after a close, + // until the cache empties, so what the track left behind still ages out. + #wake(at: number): void { + if (this.#pruneTimer !== undefined && this.#pruneTimerAt <= at) return; + clearTimeout(this.#pruneTimer); - const maxAgeMs = this.#state.info.peek()?.maxAge; - if (maxAgeMs === undefined) return; // setTimeout truncates its delay to a signed 32-bit int, so a longer window // would fire immediately and spin. Wake at the cap instead and re-arm: #prune // retains anything still fresh, so the extra wakeups are the only cost. - const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, oldest + maxAgeMs - performance.now())); + const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, at - performance.now())); const timer = setTimeout(() => { this.#pruneTimer = undefined; this.#prune(); @@ -656,12 +727,15 @@ export class Producer { // A cache prune is never a reason to hold a Node/Bun process open. (timer as unknown as { unref?: () => void }).unref?.(); this.#pruneTimer = timer; + this.#pruneTimerAt = at; } // Retain a source group and fan it out to every live sink. #publish(group: GroupProducer): void { - const entry: CachedGroup = { group, time: performance.now(), mirrors: new Map() }; + const entry: CachedGroup = { group, mirrors: new Map() }; this.#cache.push(entry); + this.#cached.set(group.sequence, entry); + this.#received = Math.max(this.#received, group.sequence + 1); for (const sink of this.#sinks) this.#mirror(entry, sink); // Give held mirrors the new live edge before pruning their timeline entry, // so their latency guard can preserve a terminal expiry verdict. @@ -699,14 +773,13 @@ export class Producer { writeGroup(group: GroupProducer) { this.#writable(group.sequence); - const existing = this.#cache.findIndex((entry) => entry.group.sequence === group.sequence); - if (existing >= 0) { - const entry = this.#cache[existing]; - if (!(entry.group.closed.peek() instanceof Error)) { + const existing = this.#cached.get(group.sequence); + if (existing) { + if (!(existing.group.closed.peek() instanceof Error)) { throw new Error(`duplicate group: sequence=${group.sequence}`); } - this.#evict(entry); - this.#cache.splice(existing, 1); + this.#evict(existing); + this.#uncache(existing); } // Only advance the shared counter upward (for appendGroup auto-increment). @@ -721,6 +794,7 @@ export class Producer { // Fan a datagram out to every live subscriber, dropping the oldest once the ring is full. // Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache). #publishDatagram(datagram: Datagram): void { + this.#received = Math.max(this.#received, datagram.sequence + 1); for (const sink of this.#sinks) { sink.datagrams.mutate((list) => { if (list.length === MAX_DATAGRAMS) list.shift(); @@ -800,21 +874,41 @@ export class Producer { * Close the track and every subscriber, mirroring the abort to their groups. Idempotent. * * A clean close keeps the end {@link finishAt} declared, or declares one past the highest - * sequence produced; an abort ends without one. + * sequence produced; an abort ends without one. Subscribers still draining get the + * finished groups first, then the end or the abort. An abort after the declared end + * settled (reached, with every group below it finished) is a clean close. The groups + * left behind still age out after the track's `maxAge`, so a stale subscriber can't pin + * them. */ close(abort?: Error) { - if (abort === undefined && this.#state.closed.peek() === undefined && this.#state.final.peek() === undefined) { + if (this.#state.closed.peek() !== undefined) return; + if (abort && this.#settled()) abort = undefined; + if (abort === undefined && this.#state.final.peek() === undefined) { this.#declareFinal(this.#sequence.next); } + // Nobody will finish these, so a subscriber that has not taken one yet never sees it. + // Not evicted: a reader already holding one keeps its frames and sees the abort. + const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : []; closeTrackState(this.#state, abort); - clearTimeout(this.#pruneTimer); - this.#pruneTimer = undefined; for (const { group } of this.#cache) group.close(abort); + for (const entry of open) { + this.#unlink(entry); + this.#uncache(entry); + } for (const sink of this.#sinks) { for (const group of sink.groups.peek()) group.close(abort); closeTrackState(sink, abort); } this.#sinks.clear(); + this.#prune(); + } + + // Whether the declared end was reached and every cached group below it finished, so + // the track already holds everything it promised. Mirrors the Rust `is_settled`. + #settled(): boolean { + const final = this.#state.final.peek(); + if (final === undefined || this.#received < final) return false; + return this.#cache.every(({ group }) => group.sequence >= final || group.closed.peek() === null); } /** Append a frame as its own single-frame group. */ @@ -876,16 +970,17 @@ export class Subscriber { end?: number; } { const { end } = this.#cursor.peek(); + const timeline = this.#state.timeline; let presentation: { sequence: number; timestamp: Timestamp } | undefined; - for (const { group } of this.#state.timeline.values()) { - if (end !== undefined && group.sequence >= end) continue; + // The edge wants the newest content that exists, so it takes the newest + // stamped group's latest frame: walk back from the cap to the first one. + for (let i = (end === undefined ? timeline.length : timelineIndex(timeline, end)) - 1; i >= 0; i--) { + const group = timeline[i]; if (group.closed.peek() instanceof Error) continue; - // The edge wants the newest content that exists, so it takes the newest - // stamped group's latest frame. const timestamp = hooks.groupTimestamp(group); - if (timestamp !== undefined && (!presentation || group.sequence > presentation.sequence)) { - presentation = { sequence: group.sequence, timestamp: hooks.groupLatest(group) ?? timestamp }; - } + if (timestamp === undefined) continue; + presentation = { sequence: group.sequence, timestamp: hooks.groupLatest(group) ?? timestamp }; + break; } const requested = this.#state.update.peek()?.maxAge ?? 0; @@ -910,15 +1005,14 @@ export class Subscriber { // unstamped successor will begin, and shrinking the bound is the unsafe direction. // An unstamped successor leaves the reach unbounded until it presents a frame. #reach(sequence: number, end?: number): number | undefined { - let successor: GroupConsumer | undefined; - for (const { group } of this.#state.timeline.values()) { - if (group.sequence <= sequence) continue; - if (end !== undefined && group.sequence >= end) continue; - if (group.closed.peek() instanceof Error) continue; - if (!successor || group.sequence < successor.sequence) successor = group; + const timeline = this.#state.timeline; + for (let i = timelineIndex(timeline, sequence + 1); i < timeline.length; i++) { + const successor = timeline[i]; + if (end !== undefined && successor.sequence >= end) break; + if (successor.closed.peek() instanceof Error) continue; + return hooks.groupTimestamp(successor)?.asMillis(); } - if (!successor) return undefined; - return hooks.groupTimestamp(successor)?.asMillis(); + return undefined; } // Whether the drift budget says to give up on `group`. @@ -945,8 +1039,7 @@ export class Subscriber { end?: number; }, ): boolean { - const candidate = this.#state.timeline.get(group.sequence); - if (candidate?.group !== group) return false; + if (this.#state.timeline[timelineIndex(this.#state.timeline, group.sequence)] !== group) return false; const reach = this.#reach(group.sequence, drift.end); return ( @@ -1145,7 +1238,7 @@ export class Subscriber { }); this.#frameGroup?.close(abort); this.#frameGroup = undefined; - this.#state.timeline.clear(); + this.#state.timeline.length = 0; } /** diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 38b5a87143..5ac73aad85 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -50,7 +50,7 @@ export interface OriginProducer { export interface OriginConsumer { routes(path: Path.Valid): boolean; readonly broadcasts: Getter | undefined>; - readonly advertised: Getter | undefined>; + readonly advertised: Getter; /** The announced local broadcast at `path`, when it is the route peers are offered there. */ local(path: Path.Valid): broadcast.Consumer | undefined; demand(path: Path.Valid): Promise; @@ -60,10 +60,16 @@ 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. */ + /** The paths a scoped route may serve beneath its prefix, relative like its key; unset for the whole subtree. */ readonly claim?: Path.Patterns; } +/** + * Every originated advertisement per prefix, most preferred first. A reader takes the first + * one its scope admits, so a cheaper route it cannot use never hides one it can. + */ +export type Advertisements = ReadonlyMap; + /** The protocol-facing operation behind an established session. */ export interface Established { consume(path: Path.Valid): broadcast.Consumer; diff --git a/js/pattern/src/index.test.ts b/js/pattern/src/index.test.ts index 0378be70ba..d05b94ccad 100644 --- a/js/pattern/src/index.test.ts +++ b/js/pattern/src/index.test.ts @@ -6,7 +6,7 @@ import { compareSpecificity, IntersectionError, InvalidPattern, Pattern, Pattern interface Vectors { parse: { text: string; canonical?: string; segments?: Segment[] | null; error?: InvalidPattern.Code }[]; literal: ({ path: string; pattern: string } | { path: string; error: InvalidPattern.Code })[]; - subtree: { path: string; pattern: string }[]; + subtree: ({ path: string; pattern: string } | { path: string; error: InvalidPattern.Code })[]; head: { pattern: string; head: string; literal: boolean; globstar: boolean }[]; matches: { pattern: string; path: string; expect: boolean }[]; contains: { outer: string; inner: string; expect: boolean }[]; @@ -68,7 +68,14 @@ describe("vectors", () => { ).toBe(c.error); else expect(Pattern.literal(c.path).text, c.path).toBe(c.pattern); } - for (const c of vectors.subtree) expect(Pattern.subtree(c.path).text, c.path).toBe(c.pattern); + for (const c of vectors.subtree) { + if ("error" in c) + expect( + errorCode(() => Pattern.subtree(c.path)), + c.path, + ).toBe(c.error); + else expect(Pattern.subtree(c.path).text, c.path).toBe(c.pattern); + } }); test("head", () => { diff --git a/js/pattern/src/index.ts b/js/pattern/src/index.ts index c95676ea42..f5e367902c 100644 --- a/js/pattern/src/index.ts +++ b/js/pattern/src/index.ts @@ -421,9 +421,16 @@ export class Pattern { return new Pattern(splitPath(path).map((value) => ({ kind: "literal", value }))); } - /** The pattern matching `path` and everything beneath it: `path/**`. The empty path yields `**`. */ + /** + * The pattern matching `path` and everything beneath it: `path/**`. + * + * The empty path yields `**`, and a path of {@link Pattern.MAX_SEGMENTS} yields the + * literal, since nothing can sit beneath it. + */ static subtree(path: string): Pattern { - return new Pattern([...splitPath(path).map((value): Segment => ({ kind: "literal", value })), GLOBSTAR]); + const segments = splitPath(path).map((value): Segment => ({ kind: "literal", value })); + if (segments.length < MAX_PATTERN_SEGMENTS) segments.push(GLOBSTAR); + return new Pattern(segments); } /** The pattern matching every path: `**`. */ diff --git a/js/publish/package.json b/js/publish/package.json index f1b4cd30a7..104019bc2e 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -33,7 +33,7 @@ "@moq/json": "workspace:^", "@moq/net": "workspace:^", "@moq/signals": "workspace:^", - "mediabunny": "^1.56.2" + "mediabunny": "^1.58.1" }, "devDependencies": { "@types/audioworklet": "^0.0.100", diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index a14554d0d8..b30e82ab52 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -2,6 +2,7 @@ import { describe, expect, mock, test } from "bun:test"; import * as Moq from "@moq/net"; import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; +import { Baseline } from "../jitter"; import type { AudioFrame, Format } from "./capture"; import { Encoder, resolve } from "./encoder"; @@ -185,7 +186,7 @@ class Feed { } // An Encoder wired to a fake capture feed, recording each written frame as [timestamp, payload bytes]. -async function setup() { +async function setup(baseline = new Baseline()) { const configured = new Promise((resolve) => { LaggingAudioEncoder.onConfigure = resolve; }); @@ -218,7 +219,7 @@ async function setup() { }; const encoder = new Encoder("audio", { - broadcast: { audio: () => rendition } as never, + broadcast: { audio: () => rendition, baseline } as never, capture: capture as never, }); @@ -226,6 +227,7 @@ async function setup() { LaggingAudioEncoder.onConfigure = undefined; return { + encoder, track, rendition, feed, @@ -297,3 +299,27 @@ test("a push completing several frames keeps the encoder running", async () => { [78_700, 1], ]); }); + +// Another rendition on the same broadcast flushing with far less lateness leaves this one trailing +// it, which the catalog advertises as `delay`. +test("a rendition trailing the broadcast's earliest advertises delay", async () => { + using _webcodecs = installFakeWebCodecs(); + const baseline = new Baseline(); + using env = await setup(baseline); + const { encoder, feed } = env; + + expect(encoder.out.catalog.peek()?.delay).toBeUndefined(); + + // A sibling that flushes each frame the instant it is captured. + baseline.observe(0, performance.now() * 1000); + + // Captured 100ms ago, so this rendition flushes at least that late. + const start = performance.now() * 1000 - 100_000; + for (let index = 0; index < 4; index++) { + await feed.push({ timestamp: Time.Micro(start + index * 20_000), channels: [new Float32Array(960)] }); + } + await feed.drain(); + + expect(env.written.length).toBe(2); + expect(encoder.out.catalog.peek()?.delay).toBeGreaterThanOrEqual(100); +}); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 52e441dd18..9083fc2997 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -5,7 +5,7 @@ import type * as Moq from "@moq/net"; import { Time } from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { RenditionJitter } from "../jitter"; +import { type Baseline, Estimator } from "../jitter"; import type { AudioFrame, Capture, Format } from "./capture"; import { Gain } from "./gain"; import { Resampler } from "./resampler"; @@ -170,7 +170,7 @@ export class Encoder { #fatal = new Signal(undefined); #signals = new Effect(); - #jitter = new RenditionJitter(); + #estimator = new Estimator(); constructor(name: string, props?: EncoderProps) { // `source` moved to Audio.Capture, which renditions share. TypeScript catches this, but a @@ -266,7 +266,7 @@ export class Encoder { const fatal = effect.get(this.#fatal); if (!enabled || !format || fatal) return; - this.#encode(rendition.track, format, effect); + this.#encode(rendition.track, broadcast.baseline, format, effect); }); // When demand disappears, end the epoch with a discontinuity marker (see @@ -350,7 +350,7 @@ export class Encoder { const catalog = decoder?.config === config ? { ...config, description: decoder.description } : config; effect.set(this.#out.catalog, { ...catalog, - jitter: this.#jitter.current ? Catalog.u53(this.#jitter.current) : undefined, + ...this.#estimator.estimate, }); } @@ -371,7 +371,7 @@ export class Encoder { // Encode captured audio frames into whichever track producer is live. The broadcast owns the // track's lifetime, so this never closes it; a fatal encoder error is reported through #fatal. - #encode(track: Getter, format: Format, effect: Effect): void { + #encode(track: Getter, baseline: Baseline, format: Format, effect: Effect): void { effect.spawn(async () => { // We're using an async polyfill temporarily for Safari support. await Util.Libav.polyfill(); @@ -423,10 +423,9 @@ export class Encoder { payload: Container.Legacy.encodeFrame(frame, frame.timestamp as Time.Micro), timestamp: Time.Timestamp.fromMicros(frame.timestamp as Time.Micro), }); - const jitter = this.#jitter.observe(frame.timestamp); - if (jitter !== undefined) { + if (this.#estimator.flush(frame.timestamp, baseline)) { const catalog = this.#out.catalog.peek(); - if (catalog) this.#out.catalog.set({ ...catalog, jitter: Catalog.u53(jitter) }); + if (catalog) this.#out.catalog.set({ ...catalog, ...this.#estimator.estimate }); } }, error: (err) => { diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 465e221dd7..4c8808a616 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -3,6 +3,7 @@ import * as Container from "@moq/hang/container"; import * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, Signal } from "@moq/signals"; import { CatalogProducer } from "./catalog"; +import { Baseline } from "./jitter"; import { type Kind, Rendition } from "./rendition"; // Signals the broadcast reads. Whoever owns the backing Signal (the element, or another component @@ -70,6 +71,12 @@ export class Broadcast { // Reacquire it via an effect, since a rename swaps in a fresh producer. readonly net = new Signal(undefined); + /** + * @internal The recent minimum flush lateness across every rendition, which each encoder + * measures its catalog `delay` against. Per broadcast, so a swapped one starts fresh. + */ + readonly baseline = new Baseline(); + // The registered renditions keyed by full track name. A plain object so deep-equality detects a // key add/remove; the Rendition values compare by identity, which is stable. readonly #renditions = new Signal>>({}); diff --git a/js/publish/src/jitter.test.ts b/js/publish/src/jitter.test.ts index 0cd0678924..f2970adc85 100644 --- a/js/publish/src/jitter.test.ts +++ b/js/publish/src/jitter.test.ts @@ -1,52 +1,86 @@ -import { expect, spyOn, test } from "bun:test"; -import { JitterClock, RenditionJitter } from "./jitter"; +import { expect, test } from "bun:test"; +import { u53 } from "@moq/hang/catalog"; +import { Baseline, Estimator } from "./jitter"; test("a batch flushed at its end counts its full media span", () => { - const clock = new JitterClock(); - clock.observe(0, 0); - expect(clock.observe(0, 120_000)).toBe(120_000); - expect(clock.observe(40_000, 120_000)).toBe(80_000); + const broadcast = new Baseline(); + const estimator = new Estimator(); + estimator.flush(0, broadcast, 0); + expect(estimator.flush(0, broadcast, 120_000)).toBe(true); + expect(estimator.estimate.jitter).toBe(u53(120)); + expect(estimator.flush(40_000, broadcast, 120_000)).toBe(false); }); test("a constant lateness is not jitter", () => { - const clock = new JitterClock(); - expect(clock.observe(0, 200_000)).toBe(0); - expect(clock.observe(240_000, 440_000)).toBe(0); + const broadcast = new Baseline(); + const estimator = new Estimator(); + expect(estimator.flush(0, broadcast, 200_000)).toBe(false); + expect(estimator.flush(240_000, broadcast, 440_000)).toBe(false); + expect(estimator.estimate).toEqual({ jitter: undefined, delay: undefined }); }); test("a sliding minimum bounds slow media clock drift", () => { - const clock = new JitterClock(); - let maximum = 0; + const broadcast = new Baseline(); + const estimator = new Estimator(); for (let second = 0; second < 100; second++) { - maximum = Math.max(maximum, clock.observe(second * 1_000_000, second * 1_001_000)); + estimator.flush(second * 1_000_000, broadcast, second * 1_001_000); } - expect(maximum).toBeLessThanOrEqual(10_000); - expect(maximum).toBeGreaterThan(0); + expect(estimator.estimate.jitter).toBeLessThanOrEqual(10); + expect(estimator.estimate.jitter).toBeGreaterThan(0); }); test("a faster-than-real-time source keeps lowering the baseline", () => { - const clock = new JitterClock(); + const broadcast = new Baseline(); + const estimator = new Estimator(); for (let second = 0; second < 100; second++) { - expect(clock.observe(second * 2_000_000, second * 1_000_000)).toBe(0); + expect(estimator.flush(second * 2_000_000, broadcast, second * 1_000_000)).toBe(false); } }); -test("each rendition measures against its own minimum", () => { - const now = spyOn(performance, "now").mockReturnValue(0); - try { - const audio = new RenditionJitter(); - const video = new RenditionJitter(); - expect(audio.observe(0)).toBeUndefined(); - now.mockReturnValue(200); - // A slower encoder's constant offset is not jitter. - expect(video.observe(0)).toBeUndefined(); - now.mockReturnValue(300); - expect(video.observe(40_000)).toBe(60); - now.mockReturnValue(440); - expect(video.observe(240_000)).toBeUndefined(); - expect(audio.current).toBeUndefined(); - expect(video.current).toBe(60); - } finally { - now.mockRestore(); +test("each rendition measures jitter against its own minimum", () => { + const broadcast = new Baseline(); + const audio = new Estimator(); + const video = new Estimator(); + audio.flush(0, broadcast, 0); + // A slower encoder's constant offset is not jitter. + video.flush(0, broadcast, 200_000); + video.flush(40_000, broadcast, 300_000); + video.flush(240_000, broadcast, 440_000); + expect(audio.estimate.jitter).toBeUndefined(); + expect(video.estimate.jitter).toBe(u53(60)); +}); + +test("a rendition trailing the broadcast's earliest advertises the gap as delay", () => { + const broadcast = new Baseline(); + const audio = new Estimator(); + const video = new Estimator(); + for (let frame = 0; frame < 10; frame++) { + const timestamp = frame * 20_000; + audio.flush(timestamp, broadcast, timestamp + 5_000); + video.flush(timestamp, broadcast, timestamp + 205_000); } + expect(audio.estimate).toEqual({ jitter: undefined, delay: undefined }); + expect(video.estimate).toEqual({ jitter: undefined, delay: u53(200) }); +}); + +test("delay is a lifetime maximum", () => { + const broadcast = new Baseline(); + const audio = new Estimator(); + const video = new Estimator(); + audio.flush(0, broadcast, 0); + expect(video.flush(0, broadcast, 150_000)).toBe(true); + expect(video.estimate.delay).toBe(u53(150)); + + // Video catches up, then audio stops long enough to leave the window: neither lowers it. + expect(video.flush(100_000, broadcast, 100_000)).toBe(false); + expect(video.flush(20_000_000, broadcast, 20_000_000)).toBe(false); + expect(video.estimate.delay).toBe(u53(150)); +}); + +test("broadcasts do not share a baseline", () => { + const audio = new Estimator(); + const video = new Estimator(); + audio.flush(0, new Baseline(), 0); + video.flush(0, new Baseline(), 200_000); + expect(video.estimate.delay).toBeUndefined(); }); diff --git a/js/publish/src/jitter.ts b/js/publish/src/jitter.ts index aaa9bab0e1..33a06b9e69 100644 --- a/js/publish/src/jitter.ts +++ b/js/publish/src/jitter.ts @@ -1,18 +1,24 @@ +import * as Catalog from "@moq/hang/catalog"; + const WINDOW = 10_000_000; // 10 seconds in microseconds. type Sample = { at: number; lateness: number }; -// One rendition's recent minimum encode lateness. The queue is ordered by lateness so its head is -// the minimum in the last window, and each sample enters/leaves once. -export class JitterClock { +// The minimum flush lateness over the last window, so a media clock drifting against the wall clock +// does not ratchet forever. The queue is ordered by lateness so its head is the minimum, and each +// sample enters/leaves once. +// +// Every js/publish timestamp is `performance.now()`, so lateness compares across renditions and one +// window can be shared by a whole broadcast. +export class Baseline { #samples: Sample[] = []; #head = 0; - observe(timestamp: number, now: number): number { + // Insert a lateness observed at `now` and return the window's minimum. + observe(lateness: number, now: number): number { const cutoff = now - WINDOW; while (this.#head < this.#samples.length && this.#samples[this.#head].at < cutoff) this.#head++; - const lateness = now - timestamp; while (this.#samples.length > this.#head) { const last = this.#samples.at(-1); if (!last || last.lateness < lateness) break; @@ -26,23 +32,40 @@ export class JitterClock { this.#head = 0; } this.#samples.push({ at: now, lateness }); - return Math.max(0, lateness - this.#samples[this.#head].lateness); + return this.#samples[this.#head].lateness; } } -// One rendition's advertised maximum spread above its own recent minimum lateness. -export class RenditionJitter { - #clock = new JitterClock(); - #maximum = 0; +// One rendition's catalog `jitter` and `delay`, each a lifetime maximum in whole milliseconds. +// Mirrors `moq_mux::catalog::Estimator`. +export class Estimator { + #baseline = new Baseline(); + #jitter = 0; + #delay = 0; - get current(): number | undefined { - return this.#maximum || undefined; + // The catalog fields measured so far, each absent until nonzero. + get estimate(): { jitter?: Catalog.U53; delay?: Catalog.U53 } { + return { + jitter: this.#jitter ? Catalog.u53(this.#jitter) : undefined, + delay: this.#delay ? Catalog.u53(this.#delay) : undefined, + }; } - observe(timestamp: number): number | undefined { - const rounded = Math.ceil(this.#clock.observe(timestamp, performance.now() * 1000) / 1000); - if (rounded <= this.#maximum) return undefined; - this.#maximum = rounded; - return rounded; + // Measure a frame handed to the transport now, against the `broadcast` baseline every rendition + // in the catalog shares. Jitter is the spread above this rendition's own recent minimum lateness, + // so a constant encoder delay is not jitter; delay is how far that minimum trails the broadcast's. + // Returns whether either estimate rose. + flush(timestamp: number, broadcast: Baseline, now = performance.now() * 1000): boolean { + const lateness = now - timestamp; + const earliest = broadcast.observe(lateness, now); + const minimum = this.#baseline.observe(lateness, now); + + const jitter = Math.ceil((lateness - minimum) / 1000); + const delay = Math.ceil((minimum - earliest) / 1000); + if (jitter <= this.#jitter && delay <= this.#delay) return false; + + this.#jitter = Math.max(this.#jitter, jitter); + this.#delay = Math.max(this.#delay, delay); + return true; } } diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index 9ab5a3e905..b6b3dfbbf1 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -2,6 +2,7 @@ import { expect, spyOn, test } from "bun:test"; import * as Container from "@moq/hang/container"; import * as Moq from "@moq/net"; import { Signal } from "@moq/signals"; +import { Baseline } from "../jitter"; import { Encoder } from "./encoder"; class FakeVideoEncoder { @@ -63,7 +64,7 @@ test("encoding tracks encoder config in its child effect", async () => { track: new Signal(track), close: () => track.close(), }; - const broadcast = { video: () => rendition }; + const broadcast = { video: () => rendition, baseline: new Baseline() }; const capture = { in: { source: new Signal(undefined) }, out: { @@ -110,7 +111,7 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op }; const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, }); @@ -167,7 +168,7 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -248,7 +249,7 @@ test("every published config was probed for its own codec and dimensions", async const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -509,7 +510,7 @@ test.each(["encoder lag", "quiet startup"])("marks a rendition stalled for %s", }; const encoder = new Encoder("video/hd", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, }); diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index b8d888c26d..bf0b265018 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -13,7 +13,7 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { RenditionJitter } from "../jitter"; +import { type Baseline, Estimator } from "../jitter"; import { hardwareReliable } from "../support/video"; import type { Capture } from "./capture"; import { normalizeSource, type Source } from "./types"; @@ -156,7 +156,7 @@ export class Encoder { #lastCaptured?: Time.Micro; #lastAccepted?: Time.Micro; #lastCaptureWall?: number; - #jitter = new RenditionJitter(); + #estimator = new Estimator(); constructor(name: string, props?: EncoderProps) { this.name = name; @@ -198,7 +198,7 @@ export class Encoder { return; } - this.#encode(track, effect); + this.#encode(track, broadcast.baseline, effect); }); // Reserve against the connection for as long as this track is live. Wait @@ -229,7 +229,7 @@ export class Encoder { } // Encode captured frames into the track producer, reconfiguring when the resolved config changes. - #encode(track: Moq.Track.Producer, effect: Effect): void { + #encode(track: Moq.Track.Producer, baseline: Baseline, effect: Effect): void { const capture = effect.get(this.in.capture); if (!capture) { this.#observe({ demand: true, idle: true }); @@ -266,10 +266,9 @@ export class Encoder { })); producer.encode(frame, frame.timestamp as Time.Micro, key); - const jitter = this.#jitter.observe(frame.timestamp); - if (jitter !== undefined) { + if (this.#estimator.flush(frame.timestamp, baseline)) { const catalog = this.#out.catalog.peek(); - if (catalog) this.#out.catalog.set({ ...catalog, jitter: Catalog.u53(jitter) }); + if (catalog) this.#out.catalog.set({ ...catalog, ...this.#estimator.estimate }); } this.#lastAccepted = frame.timestamp as Time.Micro; this.#observe({ demand: true, idle: false, frame: true }); @@ -404,7 +403,7 @@ export class Encoder { codedHeight: Catalog.u53(config.height), optimizeForLatency: true, container: { kind: "legacy" } as const, - jitter: this.#jitter.current ? Catalog.u53(this.#jitter.current) : undefined, + ...this.#estimator.estimate, stalled: this.#stalled.flag(), }; diff --git a/js/wasm/README.md b/js/wasm/README.md index b836032e6c..a3f2c2f294 100644 --- a/js/wasm/README.md +++ b/js/wasm/README.md @@ -24,6 +24,9 @@ for (let group = await track?.recvGroup(); group; group = await track?.recvGroup The classes (`Moq.Session`, `Moq.Broadcast`, `Moq.Track`, `Moq.Group`) drop the `Moq` prefix since they're already namespaced under the import. +`free()` is safe while a call is pending, and rejects that handle's pending +calls. Freeing a `Session` also closes it. + ## Building `dist/` is generated, not committed. Build it from the repo root: diff --git a/justfile b/justfile index d0e8439b25..669c36022a 100644 --- a/justfile +++ b/justfile @@ -441,7 +441,8 @@ _tools $FILES="": # `_check-common` runs on every invocation, so its tools are unconditional. tools=(actionlint bun jq nix nixfmt shellcheck shfmt taplo python3 nfpm dpkg-deb envsubst rpm) scoped '^(drafts/|doc/\.vitepress/drafts\.ts$)' && tools+=(kramdown-rfc xml2rfc) - scoped '^(bench/|quest/|rs/|Cargo\.(toml|lock)$|rust-toolchain\.toml$)' && tools+=(cargo envsubst) + scoped '^(bench/|rs/|Cargo\.(toml|lock)$|rust-toolchain\.toml$)' && tools+=(cargo envsubst) + scoped '^(quest/|flake\.lock$)' && tools+=(quest) scoped '^(py/|pyproject\.toml$|uv\.lock$|rs/moq-ffi/|doc/lib/py/|doc/lib/samples\.sh$)' && tools+=(uv) scoped '^(kt/|rs/moq-ffi/|doc/lib/kt/|doc/lib/samples\.sh$)' && tools+=(gradle java) # cargo because `go check` builds moq-ffi for the host, and skips on a @@ -542,7 +543,8 @@ _check $BASE $TEST: just rs tokio-features just rs media-features just --justfile bench/justfile check - cargo run --quiet --locked --package quest -- check + quest check + just test drill-sensitivity --apply-only # Not covered by the line above: moq-wasm only exists on the wasm32 target. just rs wasm just py check @@ -565,9 +567,15 @@ _check $BASE $TEST: just drafts check fi # Quest documents form one graph, so validate the whole living tree when - # either a quest or its validator changes. - if echo "$files" | grep -qE '^(quest/|rs/quest/)'; then - cargo run --quiet --locked --package quest -- check + # either a quest or the flake-pinned validator changes. + if echo "$files" | grep -qE '^(quest/|flake\.lock$)'; then + quest check + fi + # The drill mutations patch Rust source, so a Rust change can move the + # code they target. Only nightly runs the drills against them; this + # catches a stale patch in the PR that moved its code. + if echo "$files" | grep -qE '^(rs/|test/drill/)'; then + just test drill-sensitivity --apply-only fi just py check "$files" just kt check "$files" diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt index 1de4c55100..e30c3aebbb 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt @@ -30,9 +30,8 @@ class Server internal constructor( private val publishOrigin: OriginProducer?, ) : AutoCloseable { /** - * Create a live broadcast at [path], served to incoming sessions. + * Create an unannounced broadcast at [path], served to incoming sessions once announced. * - * The origin announces the path so subscribers can discover it, becoming visible * Advertise it with `announce` after populating tracks. `end()` ends it for * good; `close()` (or `use`) releases the handle, which ends it once no * `dynamic()` handle remains. diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index f54fd64553..e4a090b4f7 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -162,6 +162,7 @@ class SmokeTest { broadcast.end() broadcast.end() assertFailsWith { consumer.subscribeTrack("events", null) } + assertFailsWith { broadcast.publishTrack("events", null) } } } } diff --git a/nix/modules/moq-relay.nix b/nix/modules/moq-relay.nix index 4dfef1232e..1a520b9874 100644 --- a/nix/modules/moq-relay.nix +++ b/nix/modules/moq-relay.nix @@ -315,10 +315,13 @@ in # Cluster configuration MOQ_CLUSTER_ROOT = cfg.cluster.rootUrl; } - // lib.optionalAttrs (cfg.cluster.mode != "none") { - MOQ_CLUSTER_TOKEN = - if cfg.cluster.tokenFile != null then cfg.cluster.tokenFile else "${cfg.stateDir}/cluster.jwt"; - } + # Public rules refuse a token, so a peer only presents one an auth server will read. + // + lib.optionalAttrs (cfg.cluster.mode != "none" && (cfg.auth.enable || cfg.cluster.tokenFile != null)) + { + MOQ_CLUSTER_TOKEN = + if cfg.cluster.tokenFile != null then cfg.cluster.tokenFile else "${cfg.stateDir}/cluster.jwt"; + } // lib.optionalAttrs (cfg.cluster.nodeUrl != null) { MOQ_CLUSTER_NODE = cfg.cluster.nodeUrl; }; diff --git a/nix/overlay.nix b/nix/overlay.nix index d82a12f552..234186c687 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -101,10 +101,11 @@ let moqCInfo = crateInfo ../rs/moq-c/Cargo.toml; # The native libraries an external linker must pass alongside libmoq.a. - # rs/moq-c/native-libs/ is the single source: build.rs bakes it into moq-c.pc - # and rs/moq-c/CMakeLists.txt reads it for in-tree consumers. This installPhase - # substitutes the find_package template directly rather than running CMake, so - # format the same list here, matching CMakeLists.txt's MOQ_NATIVE_LIBS_QUOTED. + # rs/moq-c/native-libs/ is the single source: rs/moq-c/CMakeLists.txt reads + # it for in-tree consumers, and this installPhase substitutes it into the + # moq-c.pc and find_package templates directly rather than running CMake, so + # format it the way each expects: `Libs.private` flags, and CMakeLists.txt's + # MOQ_NATIVE_LIBS_QUOTED. moqCNativeLibs = let platform = @@ -116,30 +117,20 @@ let "linux"; lines = final.lib.splitString "\n" (builtins.readFile ../rs/moq-c/native-libs/${platform}.txt); entries = builtins.filter (line: line != "" && !(final.lib.hasPrefix "#" line)) lines; - quote = - entry: - if final.lib.hasPrefix "framework:" entry then - ''"-framework ${final.lib.removePrefix "framework:" entry}"'' - else - ''"${entry}"''; + framework = entry: "-framework ${final.lib.removePrefix "framework:" entry}"; + isFramework = final.lib.hasPrefix "framework:"; in - final.lib.concatMapStringsSep " " quote entries; + { + pc = final.lib.concatMapStringsSep " " ( + entry: if isFramework entry then framework entry else "-l${entry}" + ) entries; + cmake = final.lib.concatMapStringsSep " " ( + entry: if isFramework entry then ''"${framework entry}"'' else ''"${entry}"'' + ) entries; + }; moqCArgs = moqCInfo // { - # moq-c's build.rs reads moq-c.pc.in and native-libs/*.txt at compile time to - # generate the pkgconfig file. craneLib.cleanCargoSource's default filter - # drops both, which makes build.rs skip pkgconfig generation (see the - # `if let Ok(template)` in rs/moq-c/build.rs) or fail reading the lib list, - # and the installPhase's `cp .../moq-c.pc` then fails. - src = final.lib.cleanSourceWith { - src = ../.; - name = "source"; - filter = - path: type: - (final.lib.hasSuffix ".pc.in" path) - || (final.lib.hasInfix "/rs/moq-c/native-libs/" path) - || (filterCargoSources path type); - }; + src = cleanCargoSource; cargoExtraArgs = "-p moq-c"; doCheck = false; nativeBuildInputs = with final; [ @@ -168,21 +159,40 @@ let mkdir -p $out/lib/pkgconfig $out/include $out/lib/cmake/moq-c # 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. + # paths, which a cross --target build moves. build.rs writes the header + # to include/ under its OUT_DIR. 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("/moq-c#")) | .out_dir' "$cargoBuildLog") cp "$lib" $out/lib/ cp "$gen/include/moq.h" $out/include/ - cp "$gen/lib/pkgconfig/moq-c.pc" $out/lib/pkgconfig/ + + # Rendered here rather than by build.rs: the template's paths are relative + # to the .pc, which only holds once libmoq.a sits beside pkgconfig/. + pc=$out/lib/pkgconfig/moq-c.pc + substitute ${../rs/moq-c/moq-c.pc.in} "$pc" \ + --subst-var-by VERSION "${moqCInfo.version}" \ + --subst-var-by LIBS_PRIVATE ${final.lib.escapeShellArg moqCNativeLibs.pc} + if grep -nE '@[A-Z_]+@' "$pc"; then + echo "unsubstituted placeholder in moq-c.pc (see above)" >&2 + exit 1 + fi + # Resolve the paths the way a consumer's pkg-config will, so a template + # whose libdir or includedir misses what this prefix ships fails here. + for check in libdir:libmoq.a includedir:moq.h; do + dir=$(PKG_CONFIG_PATH=$out/lib/pkgconfig pkg-config --variable="''${check%%:*}" moq-c) + if [ ! -f "$dir/''${check#*:}" ]; then + echo "moq-c.pc ''${check%%:*} $dir has no ''${check#*:}" >&2 + exit 1 + fi + done major_version="$(echo "${moqCInfo.version}" | cut -d. -f1)" substitute ${../rs/moq-c/cmake/moq-c-config.cmake.in} \ $out/lib/cmake/moq-c/moq-c-config.cmake \ --subst-var-by LIB_FILE libmoq.a \ --subst-var-by VERSION "${moqCInfo.version}" \ - --subst-var-by MOQ_NATIVE_LIBS_QUOTED ${final.lib.escapeShellArg moqCNativeLibs} + --subst-var-by MOQ_NATIVE_LIBS_QUOTED ${final.lib.escapeShellArg moqCNativeLibs.cmake} substitute ${../rs/moq-c/cmake/moq-c-config-version.cmake.in} \ $out/lib/cmake/moq-c/moq-c-config-version.cmake \ --subst-var-by VERSION "${moqCInfo.version}" \ diff --git a/package.json b/package.json index bffe7256c3..3a85e66642 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "moq", "version": "0.0.0", "devDependencies": { - "@babel/parser": "^8.0.5", - "@biomejs/biome": "^2.5.13", + "@babel/parser": "^8.0.6", + "@biomejs/biome": "^2.5.14", "concurrently": "^10.0.5", "markdown-extensions": "^2.0.0", "publint": "^0.3.24", diff --git a/py/moq-ffi/pyproject.toml b/py/moq-ffi/pyproject.toml index 93262be8b2..b0df391092 100644 --- a/py/moq-ffi/pyproject.toml +++ b/py/moq-ffi/pyproject.toml @@ -36,3 +36,6 @@ bindings = "uniffi" manifest-path = "../../rs/moq-ffi/Cargo.toml" python-source = "." module-name = "moq_ffi._uniffi" +# `maturin develop` writes the bindings into the source tree, where a later wheel build +# would pick them up again and fail on the duplicate. The build regenerates them anyway. +exclude = ["moq_ffi/_uniffi/**", "**/__pycache__/**"] diff --git a/quest/AGENTS.md b/quest/AGENTS.md deleted file mode 100644 index 16eebac7aa..0000000000 --- a/quest/AGENTS.md +++ /dev/null @@ -1,115 +0,0 @@ -# Quests - -Read this file whenever work mentions a quest or questline. - -Quests are versioned plans checked into the repository under `quest/`. GitHub -issues remain the public front door; prefer a quest for work that needs -durable scope or coordination. - -## Model - -- A quest is a Markdown file, completed in one PR. A questline is a directory - whose `README.md` is its quest: its `Quests` section lists the children, and - it completes when its own work is done and every child has merged. -- The root's entries are milestones, `m0`, `m1`, ..., grouping work by - priority horizon; lower numbers matter more. [README.md](README.md) says - what each holds. Priority, not breakage, decides the milestone, and starting - a quest does not move it. -- A document's branch is its path without `.md`: `quest/m1/foo/bar.md` is - branch `quest/m1/foo/bar`, and its line is `quest/m1/foo/README`. A quest - 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 - `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. - -## Format - -```markdown -# [S] Short title - -## Goal - -The observable outcome and important boundaries. - -## Plan - -Current decisions, open questions, or implementation guidance. - -## Quests - -- [Child quest](/quest/foo/bar.md) - the outcome, so the list reads without opening it -- [Nested questline](/quest/foo/baz/README.md) - what the whole line delivers - -## Required - -- [Blocker](/quest/bar.md) - work that must finish before this can start - -## Closes - -- [#701](https://github.com/moq-dev/moq/issues/701) - close this issue when the quest finishes - -## Related - -- [Other](/quest/other.md) - similar work that is not a blocker -``` - -- `Goal` is required; everything else is optional. Use these exact headings. -- Size the title `[XS]` to `[XL]` for implementation, verification, and - landing. A README with children carries no size; one without is a plain - quest and needs one. -- Only a README has `Quests`. `Required` lists what must finish before the - work starts; no section means ready. A required questline clears when the - whole line has merged. A plain-text bullet names a condition outside the - repository; remove it when it clears. `Required` must be acyclic. -- `quest check` enforces this structure; `just check` runs it on any branch - touching `quest/`. `quest ready []` prints what blocks a quest, or - every ready quest. `quest branch ` prints the branch and every branch - it merges through, nearest first and ending at `main`. Run them as - `cargo run --quiet --locked --package quest -- ...`. They read the tree - alone: whether a PR already claims a quest is GitHub's question. - -## Creation - -- Quests are created in PRs and reviewed. Search the tree and git history - first. -- Split independently completable work into separate quests. Group them in a - questline only when they ship together, and give the README the work no - child owns: the end-to-end test, the docs page. -- New work joins the milestone matching its priority, at its rank. -- Every issue under `Closes` carries the `quest` GitHub label - (`gh issue edit --add-label quest`), applied when the quest lands. - `Related` is context and gets none. -- A release or pin bump that unblocks work is its own quest holding the - condition as a plain-text `Required` bullet; every dependent requires it. - -## Execution - -- Start only ready quests. `quest branch` names the branch and its bases: push - each missing line branch from the one after it and open its draft PR against - that base, then push the quest's branch with an empty commit. The remote - branch is the claim; continue only if an existing one is stale (old, no open - PR). -- Set the upstream to the base so `just check` scopes against - it. Keep a line current by merging its base in; never rebase a shared branch. -- Update the quest as the plan changes. Complete it when no work remains, and - suggest follow-ups as new quests. -- Open the PR per [CONTRIBUTING.md](../CONTRIBUTING.md) against the base, with - a closing keyword for every issue under `Closes`, including those of any - questline the same PR completes. -- A line's PR stays a draft until its `Quests` list is empty. The PR that - removes the last child sizes the README's title; the README is then a ready - quest whose completion marks the line's PR ready and merges it. - -## Deletion - -- A quest that is no longer needed or cannot be completed is abandoned: delete - it and explain why in the PR. Remove the `quest` label from issues no other - quest tracks. -- Delete a quest in the PR that completes or abandons it. Grep its absolute - path and remove every reference; that reveals what it unblocks. Remove a - heading with its last entry. -- Deleting a README deletes its directory. The root and the milestones are - permanent: an empty milestone stays as a horizon. diff --git a/quest/README.md b/quest/README.md index c4c5af6425..dcc3e3b52d 100644 --- a/quest/README.md +++ b/quest/README.md @@ -7,17 +7,17 @@ grouped into milestones ordered by priority. ## Plan -m0 is everything in flight now: the release API gates, the release itself, and -the reusable Pronto GPU path. m1 is the next wave across reliability, features, +m0 is everything in flight now: announce and wildcard routing, and audio +playout (jitter target, quality harness, A/V clock). m1 is the next wave across reliability, features, performance, and planning. m2 holds later features, design studies, and experiments. m3 is deferred: work whose first step is outside this repository. m4 waits on an upstream release or external dependency to ship. Priority is separate from branch targeting: published API and wire breaks still land on dev under the repository rules. -## Quests +## Required -- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: the release API gates, the release, and the Pronto GPU path +- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: announce and wildcard routing, and audio playout - [m1: next wave](/quest/m1/README.md) - reliability, capabilities, performance, and the planning that settles their contracts - [m2: later work](/quest/m2/README.md) - deferred features, design studies, and experiments - [m3: deferred](/quest/m3/README.md) - gated on the outside world: hardware nobody has, a partner, or a provider's offer diff --git a/quest/m0/README.md b/quest/m0/README.md index da75fd356d..0f104ffe7e 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -2,102 +2,40 @@ ## Goal -Settle the public contracts of `moq-archive`, `moq-e2ee`, `moq-sock`, -`moq-uring`, `moq-audio`, `moq-video`, `moq-transcode`, and `moq-nvenc` -before the imminent release, while supplying the reusable GPU media support -needed to remove raw-pixel CPU transfers from the Pronto CARLA demo. These are -independent immediate tracks rather than mutual prerequisites. Then cut the -release moq.pro adopts from the merged tree. +The work in flight now, in two independent tracks. Routing: a publisher stops +sending announce updates the wire cannot tell apart, a service claims the +prefix it could serve instead of enumerating broadcasts. Audio playout: the target is a measured +estimate of arrival timing in both languages, a browser regression fails a +nightly run, and the audio playhead becomes the clock video follows. ## Plan -dev landed on main as #3793 on 2026-09-20; -[Release](/quest/m0/release.md) names what gates the release that follows. -Published API or wire breaks still land on dev; the quest's Plan says so. +The release API gates (#3829..#3878) and the release that followed them are +done. moq.pro tracks this repository as a submodule rather than a release, so +no release quest gates this milestone. The Pronto GPU integration lives in +moq.pro. -The archive, E2EE, and uring crates are 0.0.x; socket, audio, video, -transcode, and nvenc are 0.1.x so dependents can take compatible patches. Their -API quests gate the release; a published break to a 0.1.x crate targets dev. -Inspect transitive public exposure before changing a shared symbol: `moq-tokio` -publicly re-exports `moq-sock`'s bind module. Keep that re-export and its -current names. +Routing: announce-update dedupe is a wire-compatible fix on every version. The +wildcard line is prefix-only on the wire; its resolve and demand work is done +on the line branch and waits to land. Serving the relay's ingested-only +view (`origin::Consumer::local()`) to localhost workers belongs to moq.pro's +edge, which embeds moq-relay; it moved there on 2026-09-28. -Keep the useful boundaries: archive owns storage and codecs, E2EE owns -protection rather than catalogs, sock owns runtime-neutral sockets, and uring -owns the local worker and its I/O. Prefer standard Rust ranges to a new public -range type. Keep uring's root `Config` reachable, since worker is private; -renaming `TxBuf` or moving bind names does not improve an ownership contract. +Audio playout: the jitter target replaces the round-trip guess. The harness's +browser lane grades it nightly and records the traces it replays; the native +lane is a standalone m1 quest, since nothing here waits on it. The A/V clock +builds on the jitter target's per-track spread. -Their package boundaries are explicit: +Published API or wire breaks still land on dev; each quest's Plan says so. -- `moq-archive` replaces reversed integer-pair bounds with validated finite - `RangeInclusive` values, unifies streaming and paginated listing under - archive-owned query/entry types, and hides path helpers that are not consumer - APIs. Persisted object paths and bytes remain unchanged. -- `moq-e2ee` replaces raw/profile-global construction with application-owned - secrets and epoch-scoped `Credential`, `Generation`, `Epoch`, and track/group - handles. Raw crypto, catalog policy, retransmission internals, and the global - `Publication` registry leave the public surface. -- `moq-sock` makes incomplete reuseport groups unservable and retains every - member socket for the served group's lifetime. -- `moq-uring` derives worker and steering identity from owned sockets and - connections instead of independently supplied handles or shard values. -- Published `moq-tokio` keeps its worker signatures and `bind` re-export while - adapting internal plumbing. Its root names do not move. +## Required -The media crates are 0.1.x too, so a published break to them targets dev. -Adapt callers in other packages without breaking their published APIs, C -layouts, or wire formats. Do not bump versions as part of these quests. - -Their package boundaries are explicit: - -- `moq-audio` owns the PCM/layout and codec configuration split, decoder entry - point, publication authority, and extensible audio frame and packet - construction. -- `moq-video` owns frame conversion and construction, decoder output policy, - synchronous codec thread confinement, capture timestamps and rational rates, - extensible group configuration and `cut` naming, and its feature defaults. -- `moq-transcode` adopts the video rate, group, output, and feature contracts in - its public configuration and observations without adding another media model. -- `moq-nvenc` narrows its safe facade around owned resources and completion, - while loading and incompatibility become fallible public errors. -- Published `moq-mux` gains only the additive shared `rate` namespace. Published - `moq-ffi`, `moq-c`, and language-binding signatures, layouts, and sentinel - behavior remain unchanged while their internals adapt. - -The agreed media direction is small, honest APIs: typed PCM layouts, rational -video rates, extensible GOP and frame records, explicit ownership, and no knobs -that claim behavior they do not provide. Synchronous codecs remain public and -thread-confined; async sinks own codec execution. Native versus CPU output is a -choice, not a promise that every native backend yields a GPU surface. OpenH264 -becomes optional but stays enabled by default. Rendering becomes opt-in; -inexpensive native codec defaults remain. - -The Pronto GPU quests remain an independent deliverable within m0. They define -portable Vulkan/CUDA ownership, safe partial NVENC initialization, and a strict -GPU conversion path without changing either API audit. Product integration and -installation live in moq.pro. - -Each implementation updates its existing README, examples, and affected docs -inline and adds regression coverage to normal or nightly CI. Feature checks -exercise each media crate independently, since workspace feature unification -hides missing gates. Cross-platform compilation and hardware execution are -separate evidence. The audits were source-based, not a cryptographic review, -fresh compilation, benchmark, or Linux runtime validation. - -API-preserving implementation, codec additions, allocation work, and hardware -proof remain in the existing backlog. Keep audio's integrated packetizing -Producer and transcode's validated Ladder and coalescing active cursor. Keep -one video Frame/Surface hierarchy and its deliberate native/wgpu type interop; -do not add another media abstraction or a renderer crate during stabilization. - -## Quests - -- [Release](/quest/m0/release.md) - the release moq.pro adopts: binding docs, an upgrade page, and a staging soak gate it rather than the merge +- [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - a publisher sends an announce update only when the wire route changed +- [Wildcard](/quest/m0/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - a browser playout latency regression fails a nightly run instead of arriving as a bug report, and its recorder supplies the jitter target's replay traces - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess - [A/V clock](/quest/m0/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles -- [SD rendition for bbb](/quest/m0/bbb-sd.md) - `just pub bbb` publishes a pre-encoded 360p rung beside the 720p source, for localhost demos and moq.pro's fleet demo ## Related -- [Pronto GPU integration](https://github.com/moq-dev/moq.pro/tree/main/quest/main/pronto/gpu) - CARLA bridge, release adoption and desktop installation +- [Pronto GPU integration](https://github.com/moq-dev/moq.pro/tree/main/quest/m0/pronto/gpu) - CARLA bridge, release adoption and desktop installation diff --git a/quest/m0/announce-update-dedupe.md b/quest/m0/announce-update-dedupe.md new file mode 100644 index 0000000000..c428466a89 --- /dev/null +++ b/quest/m0/announce-update-dedupe.md @@ -0,0 +1,32 @@ +# [S] Skip unchanged announce updates + +## Goal + +A publisher sends an announce update only when what the peer would decode +differs from what it last sent for that announcement. A local change the wire +cannot express (the route's source session, `served`, captures) sends nothing. +This applies on every version, lite and IETF, in Rust and JS, and it is wire +compatible. + +## Plan + +Each announce cursor dedupes its best route on `(hops, cost, source)` plus +`served` and captures (`rs/moq-net/src/model/origin.rs`, the `Updated` kind), +but the lite publisher only remembers each suffix's Announce ID +(`rs/moq-net/src/lite/publisher.rs`, the `self.live` branch) and re-sends +`Restart` for every `Updated` it sees. Only the hops and cost reach the wire, +so a flip of any other field sends an identical update, to every peer, for +every covered broadcast. This comes from reading the code, not from a test. + +- Reproduce first: a test that flips a route's source session with the same + hops and cost, and asserts the peer receives no update. +- Keep the last-sent `(hops, cost)` beside the Announce ID and skip equal + updates. Check the IETF publisher's re-pricing path and the JS publisher for + the same pattern. +- Fix the stale comments on the way: `lite/announce.rs` says restarts are only + ever received, and the `restart_announce` doc in `lite/subscriber.rs` says it + compares the first hop. + +## Related + +- [Cluster routing](/quest/m1/cluster-routing.md) - removes the other big source of updates, reroutes that change only the hop chain diff --git a/quest/m0/audio-jitter-target/README.md b/quest/m0/audio-jitter-target/README.md index 9f9600f3e1..9ccd989e69 100644 --- a/quest/m0/audio-jitter-target/README.md +++ b/quest/m0/audio-jitter-target/README.md @@ -25,6 +25,20 @@ additive and target `main`: the native knob is a new field on a `#[non_exhaustive]` struct, and the browser estimator is a new module plus a new `spread` observation. +Decided for landing: the line merges to `main`, not `dev`, with a changelog +note for two behavior changes treated as fixes. `@moq/watch` `Sync` takes a +numeric delay literally instead of adding the rendition delay on top +([#3954](https://github.com/moq-dev/moq/pull/3954)), and `moq play --delay` +defaults to `auto` instead of `100ms` +([#3967](https://github.com/moq-dev/moq/pull/3967)). The old additive delay +was wrong, and both compile unchanged for existing callers. The line branch is +about 200 commits behind `main` with conflicts in `js/watch/src/sync.ts` and +`rs/moq-cli`; merge `main` in (never rebase the shared branch) before +finishing the watch quest. The raw #3477 traces are gone, so record fresh +traces with the [audio quality +harness](/quest/m0/audio-quality-harness/README.md) instead of asking the +reporter; they replace the #3477 traces wherever the quests name them. + The algorithm is written down at `doc/concept/audio-jitter.md`, with a conformance corpus beside it that both implementations will read. @@ -60,7 +74,7 @@ playback may drift from the live edge before skipping a stalled group, and `start`, where to begin on a track that already holds groups. Nothing pads the buffer against uneven arrivals. -## Quests +## Required - [Watch](/quest/m0/audio-jitter-target/watch.md) - js/watch and js/hang bring the #3517 branch's estimator into conformance - [Native](/quest/m0/audio-jitter-target/native.md) - rs/moq-audio grows a measured jitter buffer from the same algorithm @@ -71,6 +85,6 @@ buffer against uneven arrivals. ## Related -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - the automated proof, built on its own schedule +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - the automated proof, and the recorder of the traces the watch quest replays - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - inaudible convergence, on top of this - [Plan: A/V clock](/quest/m0/plan-av-clock.md) - the clock this target eventually feeds diff --git a/quest/m0/audio-jitter-target/watch.md b/quest/m0/audio-jitter-target/watch.md index 188de90dc9..a1a258f61e 100644 --- a/quest/m0/audio-jitter-target/watch.md +++ b/quest/m0/audio-jitter-target/watch.md @@ -74,11 +74,11 @@ against it is likely cheaper than patching the branch's: came up on the 100 ms chip, so something is restoring or overriding it. Pin that down: a stored preference silently winning over the default is its own bug, and it also means auto gets far less real exposure than it looks like. -- Replay the recorded traces from #3477 rather than synthetic ones of the same - shape. They are on the reporter's fork (`fperex/moq`, branch - `debug/rt-audio`) with the raw ndjson attached to release - `rt-audio-traces-2026-09-06`. Trim a copy into the repository and replay it - through both rings in `replay.test.ts`. +- Replay recorded traces rather than synthetic ones of the same shape. The + #3477 traces are gone, so record fresh ones with the [browser + harness](/quest/m0/audio-quality-harness/browser.md) (decided with the + maintainer instead of asking the reporter). Trim a copy into the repository + and replay it through both rings in `replay.test.ts`. - Manual run against the public relay on Chrome and Safari, the two rows the issue measured. Measure the publisher's audio encoder input-to-output lag in the same run using the reporter's instrumented harness; #3518 fixed the known @@ -95,6 +95,10 @@ reading `probe`; removing it is part of the `SyncInput` reshape in [Plan: A/V clock](/quest/m0/plan-av-clock.md). Land the estimator so that quest can adopt it without a second estimator change. +## Required + +- [Browser harness](/quest/m0/audio-quality-harness/browser.md) - records the arrival traces this quest replays + ## Related - [Plan: A/V clock](/quest/m0/plan-av-clock.md) - reshapes `SyncInput` around the per-track spread this quest produces diff --git a/quest/m1/audio-quality-harness/README.md b/quest/m0/audio-quality-harness/README.md similarity index 59% rename from quest/m1/audio-quality-harness/README.md rename to quest/m0/audio-quality-harness/README.md index a40a280920..457844c109 100644 --- a/quest/m1/audio-quality-harness/README.md +++ b/quest/m0/audio-quality-harness/README.md @@ -6,8 +6,10 @@ A regression in audio playout latency fails a run instead of arriving as a bug report. The harness plays a broadcast over an impaired path, counts what the listener would actually have heard (underruns, short quanta, discarded samples, skip-aheads) and what each stage of the pipeline contributed to the delay, and -grades the result against a checked-in budget. It runs in the browser and -natively, on the same jitter profiles, reporting the same numbers. +grades the result against a checked-in budget. This line is the browser; +the native lane is [Native audio +quality](/quest/m1/audio-quality-native.md), on the same jitter profiles and +reporting the same numbers. Boundaries: audio only. The stage breakdown is defined generically so video can adopt it later, but no video assertion ships here. No perceptual scoring: the @@ -15,17 +17,20 @@ grade is glitches and latency, not an opinion about how it sounds. ## Plan -Two quests, browser first, because that is where the traces and the reported -bug both are. The native lane follows against the same budgets and the same -metric schema, so the two implementations can be compared rather than merely -both passing. +Browser only, because that is where the traces and the reported bug both +are. The native lane moved to m1 as a standalone quest (decided in the +2026-09-28 quest audit): nothing in m0 waits on it, and it follows against the +same budgets and metric schema so the two implementations can be compared +rather than merely both passing. The starting point is not a blank page. The reporter on #3477 already built a -working browser harness on their fork (`fperex/moq`, branch `debug/rt-audio`): -a CDP driver, a beacon sink, a trace analyzer, a ring replay, a five-scenario -`bench.sh`, and a `compare.mjs` that prints before-and-after tables, with 130 -raw ndjson traces attached to release `rt-audio-traces-2026-09-06`. Upstream -that rather than reinventing it. +working browser lane on their fork (`fperex/moq`, branch +`debug-findings-solution`, under `test/audio-quality/`): a Playwright-driven +matrix over `moq-shaper`, a budget file graded under `--enforce`, a nightly +job, and a replay runtime. Upstream that rather than reinventing it. The raw traces it shipped with are gone, so +this harness records fresh ones, and the [jitter target's watch +quest](/quest/m0/audio-jitter-target/watch.md) replays them (decided with the +maintainer during the merged-PR audit). Jitter comes from the seeded userspace UDP shaper the transport drills run under (`rs/moq-shaper`, documented in `test/drill/README.md`), not from a fake @@ -42,13 +47,13 @@ sample rate, which is more than a merge gate should carry, and `nightly.yml` already exists for exactly this trade. Budgets are keyed by the full row, since each of those dimensions moves the expected floor. -## Quests +## Required -- [Browser](/quest/m1/audio-quality-harness/browser.md) - upstream the fork's harness, grade it against a budget, run it nightly -- [Native](/quest/m1/audio-quality-harness/native.md) - the same profiles and budgets through `moq play` on a dummy device +- [Browser](/quest/m0/audio-quality-harness/browser.md) - upstream the fork's harness, grade it against a budget, run it nightly ## Related +- [Native audio quality](/quest/m1/audio-quality-native.md) - the same profiles and budgets through `moq play` on a dummy device - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the estimator this exists to keep honest - [Latency ledger](/quest/m2/latency-ledger.md) - promotes this harness's probes into a public API - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - graded by this harness once it lands diff --git a/quest/m1/audio-quality-harness/browser.md b/quest/m0/audio-quality-harness/browser.md similarity index 77% rename from quest/m1/audio-quality-harness/browser.md rename to quest/m0/audio-quality-harness/browser.md index f73d5bb8dc..a47aed7969 100644 --- a/quest/m1/audio-quality-harness/browser.md +++ b/quest/m0/audio-quality-harness/browser.md @@ -13,16 +13,18 @@ production path is the one without cross-origin isolation. ## Plan -Upstream the reporter's harness from `fperex/moq` branch `debug/rt-audio` -rather than rebuilding it, keeping the attribution. It already has the CDP -driver, the beacon sink, the analyzer, the ring replay, `bench.sh`'s five -scenarios, and `compare.mjs`. What it does not have is a home in `test/`, a -budget, or a schedule. +Upstream the reporter's lane from `fperex/moq` branch +`debug-findings-solution` rather than rebuilding it, keeping the attribution. +It already has a built `test/audio-quality/` lane: `just test audio-quality` +over `moq-shaper`, a Playwright-driven Chromium matrix, a `budgets.json` +graded by `grade.ts` under `--enforce`, a nightly job that keeps a failure's +run directory, and `chromium`, `safari`, and `replay` runtimes. It also +carries player, estimator, and shaper changes (checked 2026-09-28: 222 commits +ahead of and 148 behind `main`), so land the lane on its own and hold its +schema to the contract below rather than taking the branch wholesale. -- Land the driver and analyzer under `test/`, alongside the existing `interop` - and `drill` lanes, wired into the `justfile` the way they are. Playwright is - already in the tree for the harness quests, so prefer it over a bespoke CDP - driver if the switch is cheap; if it is not, say so and keep CDP. +- Land the lane under `test/`, alongside the existing `interop` and `drill` + lanes, wired into the `justfile` the way they are. - Keep the instrumentation ad-hoc for now. The probes stay a debug surface, not public API; promoting them is [Latency ledger](/quest/m2/latency-ledger.md), which nothing here waits on. @@ -64,8 +66,9 @@ budget, or a schedule. each of those moves the expected floor. A profile-only key silently grades one row against another's threshold. Tightening a budget is then a visible diff and loosening one needs a reason in review. -- Trim the released ndjson traces from `rt-audio-traces-2026-09-06` into a - fixture and replay them too, so a real recorded arrival pattern is graded - next to the synthetic profiles. +- Record real arrival traces (the #3477 release traces are gone), trim them + into a fixture, and replay them too, so a real recorded arrival pattern is + graded next to the synthetic profiles. The jitter target's watch quest + replays the same fixture. - Add the lane to `nightly.yml`, and extend its header comment with why this one is not a PR gate. diff --git a/quest/m0/bbb-sd.md b/quest/m0/bbb-sd.md deleted file mode 100644 index 65d632c84a..0000000000 --- a/quest/m0/bbb-sd.md +++ /dev/null @@ -1,31 +0,0 @@ -# [S] SD rendition for the bbb demo - -## Goal - -`just pub bbb` publishes `bbb.hang` with two video renditions, the 720p -source and a 360p ~600 kbps rung, and a player watching it through `just dev` -(including moq.dev's pages on localhost) switches between them on bandwidth -and viewport size. The SD rung is pre-encoded, so publishing costs no encode -CPU. moq.pro's always-on demo consumes the same asset. - -## Plan - -- Encode `bbb-sd.mp4`: video only, H.264 360p ~600 kbps, from `bbb.mp4` with - identical frame count and timestamps, and keyframes forced at the source's - keyframe times so both renditions switch on the same boundaries. Fragment it - like the other assets. Add the encode as a `demo/pub` recipe so it is - reproducible, then `just upload bbb-sd.mp4`; `bbb.mp4` stays unchanged for - its other consumers. -- `bbb` downloads both and feeds them as two `-stream_loop -1 -re` inputs to - one ffmpeg with `-map 0 -map 1:v -c copy` into `import ts`, which turns each - video PID into its own rendition. Map the 720p stream first: WHEP and - non-multitrack RTMP still serve the first rendition by name until - [egress rendition pick](/quest/m1/egress-rendition-pick.md) lands. -- Verify: the catalog lists both renditions with a `bitrate`; the player - switches down under throttling and back up; the two renditions stay - timestamp-aligned after several loops (two looped inputs drift if their - durations differ). - -## Related - -- [moq.pro demo simulcast](https://github.com/moq-dev/moq.pro/blob/main/quest/m0/demo-simulcast.md) - the always-on fleet demo switches to this asset diff --git a/quest/m0/plan-av-clock.md b/quest/m0/plan-av-clock.md index ca329d397b..c82e3ff855 100644 --- a/quest/m0/plan-av-clock.md +++ b/quest/m0/plan-av-clock.md @@ -14,13 +14,14 @@ the next re-anchor. Settled: per-track handles, and this quest lands them. `sync.track("audio")` and `sync.track("video")` each report their advertised delay and measured spread, and one is nominated as the clock source. `SyncInput` -(`js/watch/src/sync.ts:21-46`, today `delay`, `buffer`, `probe`, `audio`, -`video`) breaks once, and a third track joins without another pair of inputs. -The measured spread per track comes from -[Watch](/quest/m0/audio-jitter-target/watch.md); its branch carries flat -`audioSpread` and `videoSpread` inputs in place of `probe`, which this quest -folds into the handles. `SyncInput` is a published `@moq/watch` shape, so -the break lands on dev. +(`js/watch/src/sync.ts`, today `delay`, `buffer`, and `probe`) breaks once, +and a third track joins without another pair of inputs; `Sync.register` +already keeps one jitter entry per track and grows into the handles. The +measured spread per track comes from the [Audio jitter +target](/quest/m0/audio-jitter-target/README.md) line, whose watch branch +replaces `probe` with per-track spread inputs that this quest folds into the +handles. `SyncInput` is a published `@moq/watch` shape, so the break lands on +dev. Recommendations for the implementation: @@ -29,23 +30,30 @@ Recommendations for the implementation: postMessage path (`js/watch/src/audio/ring-buffer.ts`) the worklet posts an estimate and the main thread extrapolates between posts. - Video reads a locally extrapolated clock, re-synced once per audio quantum, - so the per-frame `sync.wait()` (`js/watch/src/video/decoder.ts:332`) never + so the per-frame `sync.wait()` (`js/watch/src/video/decoder.ts`) never crosses a thread. - Transitions. On mute or audio track end the reference falls back to the wall clock at the last audio-derived value, so video does not jump. A ring re-stall reads as the playhead pausing, and the reference pauses with it. -- Reset coupling stays: `` already flushes the ring alongside - `sync.reset()` (`js/watch/src/element.ts:301`, `:620-621`). +- Reset coupling stays: `Player.reset()` (`js/watch/src/player.ts`) already + flushes the audio ring alongside `sync.reset()`. - The text renderer is the third track: it reads `sync.now()` - (`js/watch/src/text/renderer.ts:261`) to drive the cue clock and prune cues - at `:263-265`. + (`js/watch/src/text/renderer.ts`) to drive the cue clock and prune cues. +- Close the player gaps [#4170](https://github.com/moq-dev/moq/pull/4170) + left, since the handles own them. `Sync.received` only ever lowers its + reference, so after the earliest subscribed track leaves, playback stays + anchored to it; a track's handle going away must release its part of the + reference, the same expiry the jitter target's arrival minimum has. Text + renditions register no floor with `Sync`, so their catalog `delay` is + ignored; the text handle registers one like audio and video. The MSF + catalog schema (`js/msf/src/catalog.ts`) accepts a negative `delay` and + folds it into absent; refuse it on decode instead. ## Required -- [Watch](/quest/m0/audio-jitter-target/watch.md) - lands the per-track spread inputs this shape carries +- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the estimator this sits on, and the per-track spread inputs this shape carries ## Related -- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the estimator this sits on - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - stretching needs a clock to converge toward - [Watch worker](/quest/m1/watch-worker.md) - moves `Sync` into a worker afterwards; keep the handles free of main-thread assumptions diff --git a/quest/m0/release.md b/quest/m0/release.md deleted file mode 100644 index 1e34a92ce5..0000000000 --- a/quest/m0/release.md +++ /dev/null @@ -1,91 +0,0 @@ -# [M] Cut the release moq.pro adopts - -## Goal - -The first release from the merged tree is the one moq.pro pins: every -binding's docs match the surface it exposes, an upgrade page walks a -consumer from the last main release to this one, and the merged relay has -run on staging long enough that the origin and HLS rewrites are trusted. -The binding restructure in [FFI shape](/quest/m1/ffi-shape/README.md) -follows this release rather than riding it. - -## Plan - -Write `doc/setup/upgrade.md`, one section per package group, each break -with its PR and the replacement call: - -- net: announcements are prefix routes (#3225); serving folds into - `origin::Producer::dynamic` and broadcasts announce themselves (#3400, - #3581); `Reload`/`Shared` collapse into one `Connection` (#3614, #3636); - `writeDatagram` is `insertDatagram` (#3666); reader group and frame limits - are explicit (#3647); groups expire on timestamps alone; an oversized group - aborts with GROUP_TOO_LARGE instead of shedding its head (#3585); the - `Latency` type became `max_age` and delivery order became the `Ordered` - handle (#2688, #2955); the four moq-lite stream codes sent from the - reserved range moved to 0x36-0x39 in the draft's own range; subscriptions resume - across routes sharing a first hop (#3312); the send estimate is split among - JS publishers (#3616); @moq/net and @moq/pattern mirror Rust (`consume`, `Time.Milli`, - one `readFrame()`, `InvalidPattern`); the announce and request names; and - moq-tokio's names sit under their modules (`connection::Goaway`, `cli::Duration`, - `transport::Session`, `watch::Files`, `resolve()`; #3745). -- hang and json: the catalog `timeline` is `archive` (#3612); `json` and - `binary` catalog sections (#3109) take one options object (#3640); Rust - `modify()` is fallible and a failed dropped edit aborts the track (#3644), - paired with JS `update()`/`mutate()` and the Rust `mutate()`; an fMP4 export - fragment is a group (#3573). -- watch and play: `latency` splits into `delay` and `buffer` and - `--latency-max` is renamed (#3396); `moq play` has a real playout clock with - `--delay` (#3528); Firefox hardware encoding and screen-source scaling - (#3535). -- relay and CLI: embedders own listeners and workers (#3638); the cluster - origin is constructed once (#3582); LAN discovery is partitioned by - application (#3621) and meshes CLI and relay peers (#3648); config merges - with provenance (#3587); auth is one contract, a `Request` in and a - `Grant` with a lease out, and `--auth-api-mode` is gone (#3688); the CLI parses - with usage-rs and refuses the flags it dropped (#3030); moq-native is - moq-tokio (#2896). Released spellings refuse rather than warn or silently - alias: `--cluster-linger` is gone; `--cluster-connect` needs a full URL; - TOML `connect`/`failover_delay`/`listen`/`disable_verify`/`[server]`/`[client]` - name `url`/`race`/`bind`/`insecure`/`[listen]`/`[connect]`; CLI `--origin`/ - `--name`/`--latency-max` and `publish`/`subscribe` name `--hop`/`--broadcast`/ - `--max-age` and `import`/`export`. Unused `#[deprecated]` items are gone. - JS `announced()` always drops reflected announces (`ignoreSelf` is gone); - an `oct` JWK without `kty` is refused. The gstmoq properties - `estimated-send-bitrate`/`estimated-recv-bitrate` are `estimated-*-rate` - with no alias, a runtime failure for a `gst-launch` line. -- bindings: the Go module is `moq.dev/moq` with `context.Context` on every - blocking call (#2957); `MoqAudioCodec` is an `opus()` object (#3671); the - configuration setters are fallible (#3642); durations are microseconds and - the rate estimates are `estimated_*` (#3744); `MoqVideoDecodedFrame` is an - object owning its decoded surface, `pixels(format)` replaces - `MoqVideoDecoderOutput.format`, and `native` opts into a `native()` view - (#4094). - -Release-notes outline, the additions worth leading with, in order of value to -a consumer: prefix routes and wildcard Pattern events (#3225, #3649); -self-announcing broadcasts and `dynamic` handles (#3400, #3581); the archive -catalog and store (#3612); the delay/buffer split and playout clock (#3396, -#3528); GROUP_TOO_LARGE (#3585); explicit reader limits and timestamp-only -expiry (#3647); publish robustness (Firefox hardware encoding, file demux, -`stalled` on lagging renditions #3630, stream resets at boundaries #3580); -relay embedding and the LAN mesh (#3638, #3648, #3621, #3587); one auth -contract with leases (#3688, #3739); data tracks and captions (#3109, #3640); one `Connection` with URL -replacement (#3614, #3636); first-hop resume and the shared send estimate -(#3312, #3616). The m0 release API quests settle archive, sock, and uring -before release without making the independent Pronto or media tracks a release -prerequisite. `moq-e2ee` ships the `moq-e2ee-00` epoch profile; the -[E2EE](/quest/m1/e2ee/README.md) questline owns its twin and interop. - -The soak bullet below is cleared by hand: the merged relay serves moq.pro -staging with `/metrics` watched and a fresh viewer joining a days-old -`moq import ts` broadcast over HLS at the end; the bounded `moq_json::window` -timeline (#3240) is what makes that hold, and only a long run proves it. dev -landed on main as #3793; before cutting, run `just check --all` and -`just test interop --all` on the release revision and record it. Then cut the release under the existing release-plz and npm workflows; this -quest bumps no versions itself. - -Public API: none beyond the required quests. Wire: none. - -## Required - -- The merged relay has soaked on moq.pro staging and the maintainer has signed it off diff --git a/quest/m1/wildcard/README.md b/quest/m0/wildcard/README.md similarity index 94% rename from quest/m1/wildcard/README.md rename to quest/m0/wildcard/README.md index 05db0e70d0..9b23fbc510 100644 --- a/quest/m1/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -1,4 +1,4 @@ -# Wildcard advertisements +# [L] Wildcard advertisements ## Goal @@ -38,7 +38,10 @@ widest prefix that covers it (`**` is the root) and the request is the authority, so the advertise half of this questline is re-scoped to prefix claims resolved against pattern interest. The three workloads above still hold: the transcoder claims the root and refuses what it will not serve. -Resolve and Demand are additive and land on main. +Resolve and Demand are additive and land on main. Both are done on the line +branch (#4050 re-resolves on a refusal; 9d059b1b9 lists and demands covered +renditions in the browser player), so main no longer lists them; the line +branch moves to `quest/m0/wildcard/README` to match this path. ### What already exists, and what does not @@ -64,7 +67,7 @@ the requester's excluded hop, ordered by `route_order` (`rs/moq-net/src/model/origin.rs:633`), served on demand by the session that announced it and cached per prefix in `ServeState.served` (`:764`). That is the split-horizon-safe lookup the old `origin::Dynamic` could not provide, and it is what -[Resolve](/quest/m1/wildcard/resolve.md) now extends rather than replaces. +resolve extends rather than replaces. Request resolution, by contrast, is still prefix-only (`best_server` in `rs/moq-net/src/model/origin.rs`). The pattern matcher itself exists: @@ -123,15 +126,13 @@ field. The seed still has a floor, because standby and running claims of equal specificity do meet: a standby concrete claim (`with_cost(1000)` is the existing per-broadcast convention) shares a tier with a running publisher's - concrete announcement and with warm-advertise's exact-path warm routes. The + concrete announcement. The floor MUST exceed the deployment's enforced maximum charged-link count times its enforced maximum link cost (32 links at cost at most 5 gives a bound of 160, with producing origins seeded at 0), or a nearby standby outranks a distant running copy and the mesh starts a second encode of a stream it is already serving. That floor replaces the ad-hoc standby bias the moq.pro (downstream) transcode worker - carries today, and it is the same stride discipline - [pop-skipping](/quest/m1/pop-skipping/README.md) states for provider - economics. + carries today. - **One cost varint, not the pair.** `Cost` is `{ warm, cold }` because a relay that is carrying a broadcast discounts the warm half. A wildcard carries nothing and can never be warm, so the two halves are provably equal and the @@ -154,7 +155,7 @@ field. composer waiting for an announcement that only demand would produce. The browser player currently enforces the opposite (`js/watch`'s `#isPathAnnounced` hides a catalog rendition with no exact-path - announcement); [Demand](/quest/m1/wildcard/demand.md) makes a covering wildcard count as + announcement); demand makes a covering wildcard count as availability there. - **Refusal is a typed stream reset, with no negative cache.** An advertiser resets a subscribe it will not serve, and the reset carries which KIND of @@ -256,21 +257,13 @@ no generation, so a client that must distinguish recording generations reads the catalog's archive entry ([archive](/quest/m1/archive/README.md)) rather than announce state. -## Quests - -- [Resolve](/quest/m1/wildcard/resolve.md) - a relay resolves a subscribe or - FETCH for an unannounced path against the best matching wildcard -- [Demand](/quest/m1/wildcard/demand.md) - the browser player subscribes to a - catalog-referenced broadcast a wildcard covers, breaking the lazy-rendition - deadlock - ## Related - [path-patterns](/quest/m1/path-patterns.md) - owns the pattern dialect and the shared matcher advertisements reuse - [archive](/quest/m1/archive/README.md) - an archive advertises the catch-all pattern, and its catalog names the generations a wildcard cannot -- [pop-skipping](/quest/m1/pop-skipping/README.md) - it owns the route cost and - the rank hash this reuses +- [Cluster routing](/quest/m1/cluster-routing.md) - origin selection by cost + with an HRW tie-break, built on this line's specificity - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - derived output moves under the source's `@` segment, which the suffix patterns still match diff --git a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md index 53be1396c0..b85a2da169 100644 --- a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md +++ b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md @@ -1,11 +1,11 @@ -# [S] hang: expose the broadcast wall clock +# [XS] hang: document the broadcast wall clock ## Goal -A browser application can read the broadcast's fixed PTS-to-wall mapping -through `js/hang`, alongside archive timeline records when present, so an application that knows its -viewers share a clock can compute the delay that renders one frame at one -instant everywhere, and a DVR view can map presentation time to wall time. +A browser application can find how to map presentation time to wall time +from `doc/lib/js/hang.md`, so an application that knows its viewers share a +clock can compute the delay that renders one frame at one instant everywhere, +and a DVR view can label its timeline. The library itself never synchronizes playback on wall time. That is the decision behind [#2278](https://github.com/moq-dev/moq/issues/2278): frame @@ -17,16 +17,15 @@ sync exchange over a track. ## Plan -Expose the catalog contract selected by the continuous broadcast clock quest, -using one mapping across tracks and source restarts. The current timeline is -a broadcast-wide segment index, not a per-rendition track. Reuse the existing -consumer and signal machinery where present; do not assume the old `setWall` -producer or create per-record clock epochs. Keep `js/watch` arrival-based Sync -unchanged. Document PTS-to-wall conversion and the requirement that an -application knows whether remote clocks are synchronized. - -Verify application access using the built-in publisher integration, including -a live-only broadcast with no archive timeline. +The API already exists: the catalog root's optional `clock` (`ClockSchema`, +`Clock`) and `wallClockTime(clock, pts, ptsTimescale)` in +`js/hang/src/catalog/clock.ts`, exported from `@moq/hang/catalog`. Only the +docs are missing. Add a short section to `doc/lib/js/hang.md`: read `clock` +from a catalog root, convert a frame's PTS with `wallClockTime`, note that a +live-only broadcast carries it without an `archive` entry, that the mapping is +fixed for the broadcast's life, and that comparing wall times across machines +requires the application to know their clocks are synchronized. Keep +`js/watch` arrival-based Sync unchanged. ## Closes diff --git a/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md b/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md index 38e28ccf8d..946a68a0a6 100644 --- a/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md +++ b/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md @@ -33,6 +33,11 @@ grant; `Options::bandwidth` documents that their own loop; the capture driver's `_reservation` goes away. Public entry points for a manual ceiling stay `Producer::set_bitrate` and `Encoder::set_bitrate`. +- The audio-codecs line branch moves the Opus encoder behind a backend seam: + the rate setter and its floor live in + `rs/moq-audio/src/encode/backend/libopus.rs` there, and + `Encoder::set_bitrate` dispatches through the backend. Target whichever + shape is on the base when this starts. - Floor: `set_opus_bitrate` refuses anything outside `opus::bitrate_floor(codec_rate, frame_size).max(500)` to `300_000 * channels` (`encoder.rs`, `rs/moq-audio/src/opus.rs`). `Policy::min` defaults to a tenth of diff --git a/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md b/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md index 0a151ec965..9f04697e6c 100644 --- a/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md +++ b/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md @@ -1,4 +1,4 @@ -# [L] js/net: decode messages synchronously from buffered bytes +# [M] js/net: decode messages synchronously from buffered bytes ## Goal @@ -20,21 +20,22 @@ write converts flow-controlled bytes into heap objects. A single-message slot was tried in #2820 and broke ordering, because `take()` cannot yield to the decoder without letting a group pop slip in between. -Every `Reader` primitive in `js/net/src/stream.ts` (`u62`, `u53`, `read`, -`string`, ...) is async and routes through a fill, even when the bytes are -already buffered. All 23 `static async decode` message decoders under -`js/net/src/lite/` are written against it, plus four `decodeMaybe` variants. - -- Give `Reader` a synchronous decode over its buffer: the primitives read from - `#buffer` and signal "incomplete" when it runs short, and one generic async - driver fills and retries. The decoders then have a single synchronous body - each; the async form is the driver applied to it, not a second copy. -- Convert all 23 decoders, so the `Reader` has one contract rather than a - sync path for control messages and an async one for the rest. +`Reader` in `js/net/src/stream.ts` already decodes synchronously: a decode is +a function over a `Cursor`, whose reads throw an internal short signal when the +buffered bytes run out. `tryDecode` returns undefined and consumes nothing in +that case, and `decode`/`decodeMaybe` are the one async driver that fills and +retries. The primitives (`u62`, `u53`, `read`, `string`, ...) are that driver +applied to the `Cursor` reads, and the group and FETCH frame loops drain every +buffered frame with `tryDecode` (`js/net/bench/frames.ts`). The 22 +`static async decode` message decoders under `js/net/src/lite/`, plus four +`decodeMaybe` variants, still await a primitive per field. + +- Convert all 26 decoders to a single synchronous body over a `Cursor`, with + the async form as `reader.decode(...)` rather than a second copy. `Message` + in `lite/message.ts` becomes a sync size-prefixed wrapper. - The publisher drains controls synchronously in its loop and `SubscriptionControls` goes away. -- Tests: a decoder given a partial buffer reports incomplete without - consuming; the publisher applies N buffered updates before the next group +- Tests: the publisher applies N buffered updates before the next group pop; a partial update with a group already ready waits for the second fill and pops the group under the new range, so incomplete is never read as "no control pending"; the flood case stays bounded. diff --git a/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md b/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md index 1ec7587a17..00547d3bf6 100644 --- a/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md +++ b/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md @@ -21,8 +21,8 @@ own `tls::reload_certs` watcher, and snapshots its own mTLS roots. endpoint. `uring::Workers::bind` reads one pair once and never reloads. The primitive already exists: `ServeCerts` implements -`rustls::server::ResolvesServerCert` (`rs/moq-tokio/src/tls.rs:2857`) and -`reload_certs` (`:2931`) swaps its contents from the file watcher. What is +`rustls::server::ResolvesServerCert` in `rs/moq-tokio/src/tls.rs`, and +`reload_certs` there swaps its contents from the file watcher. What is missing is sharing it. - Build the `ServeCerts` and its watcher once, on the shared runtime, in diff --git a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md b/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md index 43f4a0afed..891110d96d 100644 --- a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md +++ b/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md @@ -25,8 +25,9 @@ failing. Check the socket group and connection-ID steering on a surviving session while unused handles are dropped, and prove all serving stops when the group terminates. Wire the tests into normal or nightly CI. -Public API: no further `moq-tokio` ownership change. The prerequisite may -change `moq-sock`'s 0.1.x API. Wire: no format change. Close #2964 only when +Public API: no further `moq-tokio` ownership change. The hardened group +already exists (`Group::bind` and `Group::complete` over `Claim` in +`rs/moq-sock/src/shard.rs`). Wire: no format change. Close #2964 only when both the dev ownership proof and this integration are complete. ## Closes diff --git a/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md b/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md index 0db7792501..5369139899 100644 --- a/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md +++ b/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md @@ -15,22 +15,21 @@ but the Rust and JavaScript models violate different parts of that invariant. ### Rust resets sequences when a dynamic producer is replaced A closed dynamic track is removed from the broadcast's weak cache. The next -subscription creates a fresh `track::Request` (`rs/moq-net/src/model/track.rs:3779`), -and `Request::new` creates a fresh `TrackState`. Because `max_sequence` -(`:199`) is empty, both `append_group` (`:1184`) and `append_datagram` -(`:1216`) restart at sequence 0. +subscription creates a fresh `track::Request` (`rs/moq-net/src/model/track.rs`), +and `Request::new` creates a fresh `TrackState`. Because its `max_sequence` +is empty, both `append_group` and `append_datagram` restart at sequence 0. That conflicts with the relay's logical track splicing. -`resume::Producer::takeover` (`rs/moq-net/src/model/resume.rs:328`) retains +`resume::Producer::takeover` (`rs/moq-net/src/model/resume.rs`) retains the previous live edge and starts a replacement at `latest + 1`. Groups from a restarted producer are therefore filtered until its counter catches up, causing the same playback stall fixed for JavaScript in #2953. -The takeover tests in `resume.rs` (`takeover_computes_boundary` `:2313`, -`takeover_splices_mid_group` `:3257`, -`takeover_splices_a_replacement_that_resends_the_head` `:3346`, -`takeover_rolls_past_a_finished_group` `:3467`, -`takeover_after_empty_segment_keeps_live_edge` `:3617`) create their +The takeover tests in `resume.rs` (`takeover_computes_boundary`, +`takeover_splices_mid_group`, +`takeover_splices_a_replacement_that_resends_the_head`, +`takeover_rolls_past_a_finished_group`, +`takeover_after_empty_segment_keeps_live_edge`) create their replacement groups with explicit sequences, so none of them exercises `append_group()` on a restarted producer; using it there would create group 0 and leave the subscriber stalled. Those are the tests to extend. Explicit group @@ -40,7 +39,7 @@ making the catch-up window longer. ### JavaScript permits concurrent same-name dynamic producers `BroadcastProducer.subscribe()` calls the internal `subscribe` with -`register = false` (`js/net/src/broadcast.ts:49-55`). Multiple publishing-side +`register = false` (`js/net/src/broadcast.ts`). Multiple publishing-side subscriptions for the same name therefore enqueue independent requests and create independent `track.Producer` instances. @@ -59,8 +58,7 @@ multiple subscribers fanning out from it. one request and share its accepted producer. - Subscription options from all subscribers remain aggregated on that request. `track::Request` already does this in Rust: it carries `prev_subscription` - (`track.rs:3786`) and re-combines the aggregate whenever a subscriber - changes (`:3905-3919`). + and re-combines the aggregate whenever a subscriber changes. - After that producer closes, a later request creates a new producer but continues the group and datagram sequence namespace for that broadcast and name. diff --git a/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md b/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md index 059adeefb5..ac20db956b 100644 --- a/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md +++ b/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md @@ -31,6 +31,19 @@ The container signals a playhead generation, not a codec reset: native decode stops flushing on it. This quest is whether watch still calls `decoder.reset()` to drop in-flight WebCodecs chunks when that generation bumps. +Reproduce before fixing; this is likely a false positive. Since +[#3711](https://github.com/moq-dev/moq/pull/3711) timelines only move +forward: a discontinuity continues from the live edge and the Rust consumer +refuses a rewind (`TimestampRewind`), so every chunk queued before the bump is +stamped below the new group, not against a distant new timeline. A +`decoder.reset()` would also contradict the documented "not a decoder flush" +contract of the discontinuity counter (`container::Consumer::discontinuity`, +and `continuous` in `js/hang/src/container/consumer.ts`), and the same +finding was ruled a false positive for native play in +[#4374](https://github.com/moq-dev/moq/pull/4374). If a stale frame cannot be +made to surface in a browser harness, close #3056 with that evidence and +delete this quest instead of adding the reset. + ## Closes - [#3056](https://github.com/moq-dev/moq/issues/3056) - close this issue when the quest finishes diff --git a/quest/m1/3489-ts-import-stream-liveness.md b/quest/m1/3489-ts-import-stream-liveness.md index abae2dc67b..8107a665b5 100644 --- a/quest/m1/3489-ts-import-stream-liveness.md +++ b/quest/m1/3489-ts-import-stream-liveness.md @@ -32,8 +32,10 @@ audio half is visible, late, through #3372's resync line. resync message. The SRT gateway reports nothing today; that surface is [SRT import stats](/quest/m1/srt-import-stats.md). - Name and shape the counters so the TR 101 290 quest adopts them as its - `PID_error` check, and leave the catalog `stalled` bit alone; that is the - ladder and client stats work. + `PID_error` check, and leave the catalog `stalled` bit alone: the importer + already sets it for a quiet video PID (`Stream::tick` after each decode + batch, #3630). These counters add no timeout; anything that must bound a + wait on a silent PID (the shared-shift quest) brings its own. - Tests with the issue's stimulus shape: suppress one PID's PES while keeping its PCR and continuity legal, assert the row's count stops and the gap grows; audio and SCTE-35 arms. diff --git a/quest/m1/README.md b/quest/m1/README.md index 738695607c..7f1d7e3615 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -15,94 +15,135 @@ Give one agent ownership of each shared code area at a time (origin/auth, the JS Reader, audio playback, media containers and archive, bindings, worker transport, benchmark tooling); worktrees isolate commits, not semantics. -## Quests +## Required -- [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 group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection +- [Late joiner history](/quest/m1/relay-late-joiner-history.md) - a subscriber joining a relay's track from group 0 later still receives the cached finished group below the live one +- [Cluster routing](/quest/m1/cluster-routing.md) - an announcement says where a broadcast originates, not how to reach it, and a relay hears only the prefixes its clients asked for - [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 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 audio delay](/quest/m1/binding-surface.md) - moq-ffi, moq-c, 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 +- [Worker socket count](/quest/m1/worker-socket-count.md) - the moq-tokio worker test counts only its own listener's sockets +- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi and every wrapper configure and observe audio playout delay +- [moq-binary folds into moq-flate](/quest/m1/flate-binary.md) - on dev, moq-flate and @moq/flate own the opaque snapshot and stream tracks and moq-binary is deleted +- [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, flate, 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 +- [Error messages](/quest/m1/error-display.md) - Python, Go, and Dart print `MoqError` with Rust's message, as Kotlin and Swift do - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack -- [JS active count](/quest/m1/js-active-count.md) - @moq/net speaks MoQ Active Count, so its IETF announce consumers go live without a timer +- [Drain before close](/quest/m1/drain-before-close.md) - a closing client delivers its queued stream finishes, so `moq import` ends the catalog cleanly over a real relay +- [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one +- [Live in apps](/quest/m1/announce-live-apps.md) - the demo and `@moq/room` show "no broadcasts" from the `live` marker, which waits for the first session on page load - [Page-load marker](/quest/m1/announce-page-load.md) - an announcement stream opened before the first connection waits for its replay before `live` - [Empty state](/quest/m1/announce-empty-state.md) - watch, room, and the demo show "no broadcasts" once `live` arrives with nothing announced - -- [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux -- [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` +- [JS active count](/quest/m1/js-active-count.md) - @moq/net speaks MoQ Active Count, so its IETF announce consumers go live without a timer +- [Watch refusal](/quest/m1/watch-refusal.md) - `` shows an origin refusal as an error instead of sitting offline +- [kio waiter overflow](/quest/m1/kio-waiter-lost.md) - a retained `Waiter` past 8 lists stops adding a duplicate entry to lists it already recorded +- [Capture re-anchor](/quest/m1/capture-reanchor.md) - a repeating or restarting device clock never rewinds native capture during a fast backlog drain +- [Splice edge cases](/quest/m1/splice-edges.md) - an unstamped successor, a pruned segment's boundary group, and a warm head during a takeover are each handled correctly +- [Resumed groups](/quest/m1/resume-latest.md) - a half-delivered group ends once the new copy is past it, so a group-only reader never parks after a mid-group failover +- [Track tail hardening](/quest/m1/track-tail-hardening.md) - Rust and JS wait out a track's tail by the same rules, with the known hang, count, truncation, grace, and memory holes closed +- [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - every stream group in a lite subscription arrives or is dropped by name, and lite-07 drops its stream count for it +- [Session death parity](/quest/m1/session-death.md) - a local close ends tracks cleanly in both languages, and JS group readers see the session's error on session death +- [moqsrc stop](/quest/m1/moqsrc-stop.md) - moqsrc's stop blocks until its session ends, without deadlocking on a blocked pad push +- [More tests under load](/quest/m1/test-flakes-2.md) - the second round of load-only failures, fixed at the cause +- [Auth outage clock](/quest/m1/auth-outage-clock.md) - the relay and moq-auth outage tests run on a paused clock again and assert both bounds of `expires` +- [Legacy end overshoot](/quest/m1/legacy-end-overshoot.md) - browser playback survives a group that starts inside the previous group's estimated end +- [Slow group log](/quest/m1/slow-group-log.md) - a starved viewer reports skipped groups once per catch-up, not once per group +- [UnknownSession log flood](/quest/m1/unknown-session-logs.md) - streams reset before their WebTransport header stop being reported as UnknownSession at WARN +- [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main +- [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container +- [Accept-side flags](/quest/m1/cli-given-flags.md) - dial-only and local verbs refuse every `--listen-*` flag instead of ignoring it +- [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - @moq/publish gates the first catalog snapshot until every reserved track is described +- [Full codec string](/quest/m1/publish-codec-string.md) - browser-published video carries the encoder's full RFC 6381 codec string, so native players decode it +- [TS export jitter](/quest/m1/ts-export-jitter.md) - the video reorder bound follows later catalogs and the declared reorder depth, so a late B-frame never reorders TS output; an undeclared stream can reorder once per new maximum depth +- [TS import shared shift](/quest/m1/ts-import-shared-shift.md) - unflagged loop wraps move audio and video by one shift, so A/V sync holds across wraps +- [PipeWire duplicate cameras](/quest/m1/pipewire-dup-cameras.md) - a webcam lists once with PipeWire enabled +- [Catalog wall clock](/quest/m1/catalog-wall-clock.md) - `Clock::wall_clock` keeps the catalog's full precision instead of truncating to milliseconds +- [Capture control](/quest/m1/capture-control.md) - on dev, `encode::Capture` replaces `CaptureOptions` without a `clock` field (it reads the catalog's), an unsupported `cut()` errors, and dropping the last `Control` cancels in-flight opens +- [Video surface](/quest/m1/video-surface.md) - on dev, moq-ffi's `native` becomes `surface`, refused on platforms with no surface +- [HLS discontinuity sequence](/quest/m1/hls-discontinuity-sequence.md) - on dev, `Segment::discontinuity` is the absolute sequence, so every cursor agrees +- [Auth client CA](/quest/m1/relay-auth-client-ca.md) - on dev, `auth::Config::validate` and `init` take the client-CA flag, so no caller can skip the check +- [RTMP TLS only](/quest/m1/rtmp-tls-only.md) - an RTMP listener configured for TLS can refuse plaintext instead of sniffing and serving it +- [HLS linger](/quest/m1/hls-linger.md) - `moq_hls::Server` serves an ended broadcast for its playlist window plus grace, so the moq.pro edge drops its own pool +- [Remove live()](/quest/m1/remove-live.md) - on dev, importers publish stream timestamps verbatim, the catalog clock maps them to wall time, and an encoder restart becomes a new epoch +- [iroh versions](/quest/m1/iroh-lite-wip.md) - `iroh://` negotiates the configured versions, so `moq-lite-07-wip` can be opted into +- [Go and Dart doc samples](/quest/m1/doc-samples-go-dart.md) - Go and Dart doc samples compile against their wrappers +- [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass +- [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust +- [#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - one dynamic producer per track name in both languages, with the sequence namespace surviving a replacement - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers -- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships -- [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability +- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; the catalog entry and format may break in place, since no archives exist - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest -- [Setup token](/quest/m1/setup-token.md) - a moq-transport SETUP `AUTHORIZATION TOKEN` reaches the accepted handshake and the relay's auth request, so a verifier can run on it - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish -- [Tests under load](/quest/m1/test-flakes.md) - three tests that time out or run out of file descriptors under `just check` are fixed at the cause +- [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped` +- [Shaper virtual time](/quest/m1/shaper-virtual-time.md) - `moq-shaper` tests judge seeded decisions on paused time, not on wall-clock delivery under load +- [Rust compressed gate test](/quest/m1/json-compressed-gate-rs.md) - `rs/moq-json` proves a snapshot delta is gated on its encoded size +- [Shrink the JS overflow test](/quest/m1/json-rolls-snapshot-test.md) - the `js/json` roll-on-overflow test uses a small `maxGroupBytes` budget instead of megabytes of JSON - [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 -- [Retire libmoq](/quest/m1/libmoq-retire.md) - the published `libmoq` crate stops after its final release points users at `moq-c` -- [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders +- [Generated C bindings](/quest/m1/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written libmoq +- [Retire the libmoq stub](/quest/m1/libmoq-retire.md) - on dev, the published `libmoq` crate stops after its final release points users at `moq-c` +- [OBS native codecs](/quest/m1/obs-moq-video/README.md) - replace FFmpeg video and audio decoding with moq-video and moq-audio, 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 -- [NVDEC teardown](/quest/m1/nvdec-teardown.md) - dropping an NVDEC decoder no longer segfaults +- [Opus catalog rate](/quest/m1/opus-catalog-rate.md) - MKV Opus import publishes the 48 kHz codec rate in the catalog, not the OpusHead input rate +- [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - a released mp4-atom reads and writes any `dOps` channel mapping family and table +- [CMAF surround Opus](/quest/m1/cmaf-opus-surround.md) - fMP4 import and export carry an Opus channel mapping table +- [js/hang dOps pre-skip](/quest/m1/js-dops-pre-skip.md) - CMAF encoding in js/hang stops hard-coding a 312-sample pre-skip +- [GPU CI](/quest/m1/gpu-ci.md) - NVIDIA tests run nightly on a self-hosted GPU runner, and `just rs nvidia` runs them locally instead of skipping - [Capture cut test](/quest/m1/capture-cut-e2e.md) - the capture loop's keyframe throttle is tested end to end against an encoder with its own GOP - [NVENC keyframe flag](/quest/m1/nvenc-keyframe-flag.md) - NVENC flags keyframes from its reported picture type instead of scanning the bitstream -- [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms, and client stats broadcasts from publishers and viewers +- [JS rendition ranking](/quest/m1/js-ranked.md) - `@moq/hang` ranks video renditions like Rust, and `@moq/watch`'s fallback uses it +- [Audio rendition pick](/quest/m1/audio-ranked.md) - single-track FLV/RTMP and WHEP serve the best audio rendition, not the first by name +- [FLV rebind before header](/quest/m1/flv-rebind.md) - single-track FLV switches to a better rendition announced before the header +- [FLV catalog stream](/quest/m1/flv-catalog-stream.md) - on dev, `flv::Export` takes a catalog stream like fmp4, replacing `with_select` +- [Own the QUIC stack](/quest/m1/quic/README.md) - the moq-noq fork carries ACK progress, reliable reset, hierarchical scheduling, deadlines, peer limits, and qmux +- [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms, and client stats broadcasts from publishers and viewers, on dev - [Drain](/quest/m1/drain/README.md) - relay restarts drain sessions over GOAWAY instead of hard-dropping them +- [Strict Redirect::resolve](/quest/m1/redirect-resolve.md) - on dev, `Redirect::resolve` can no longer quietly turn a refused redirect into a redial - [Transport upgrade](/quest/m1/transport-upgrade/README.md) - a session that came up over WebSocket moves to QUIC once the QUIC dial lands, handing over at a group boundary -- [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 +- [Signed priority](/quest/m1/signed-priority.md) - on dev, every API priority is an `i8` with 0 as the unset midpoint, and hang's built-ins sit above it - [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 -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - a playout latency regression fails a run instead of arriving as a bug report - [Benchmark regressions in CI](/quest/m1/bench-ci.md) - PRs get a non-blocking comparison of the Criterion benches they affect, and a nightly trend on main alerts on regressions - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - retained evidence, repeated paired runs, and uncertainty for performance claims - [#3126](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench reports per-interval latency percentiles so the ramp leaves the steady state - [Relay session bench](/quest/m1/bench-relay.md) - the same scenario through moq-relay's own connection handling - [Bench coverage](/quest/m1/bench-coverage.md) - Criterion targets for moq-mux containers, the hang catalog, moq-auth verification, and moq-pattern matching +- [Stats producer bench](/quest/m1/stats-producer-bench.md) - the stats drain and encode cost per tick, swept over held paths and tiers and run nightly - [Relay profiling](/quest/m1/performance-profiles.md) - reproducible CPU and allocation captures under the existing workloads - [Browser benchmarks](/quest/m1/browser-benchmarks.md) - measure JS transport, container, decode, and render costs in an identified browser +- [Generated @moq/net](/quest/m1/rs2ts/README.md) - the browser runs moq-net as TypeScript generated from the Rust source, retiring js/net's hand-written protocol and model code - [Plan: watch worker](/quest/m1/plan-watch-worker.md) - prototype an invisible page worker against app-spawned workers, and land the jank harness that decides - [Watch worker](/quest/m1/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies - [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on every version +- [Plan: cache age-out](/quest/m1/cache-wall-eviction.md) - a swept benchmark decides whether the track cache ages groups out on wall time without a write +- [Frame slot charge](/quest/m1/frame-slot-charge.md) - a group's frame slots past the first four count against the cache pool, including capacity a released group keeps - [Relay memory](/quest/m1/relay-memory.md) - remeasure what an announcement costs after prefix routes -- [PoP skipping](/quest/m1/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup -- [Route cost in the JS origin](/quest/m1/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first - [Front parking](/quest/m1/origin-front-parks.md) - an unroutable request waits on a front instead of re-asking on every route-table move - [Publish channel count](/quest/m1/publish-audio-channel-count.md) - forcing a channel count on an Audio.Capture stops costing the subscriber gaps of silence - [JS abandonment](/quest/m1/js-subscribe-abandonment.md) - a viewer returning during IETF subscribe setup keeps its track across microtasks - [IETF stream types](/quest/m1/ietf-uni-stream-types.md) - padding streams are discarded stream-only and an unknown uni type closes the session, per draft-21 -- [#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - one dynamic producer per track name in both languages, with the sequence namespace surviving a replacement - [Epoch primitive](/quest/m1/epoch.md) - one `Epoch` type in moq-net and @moq/net, carried as a trailing `@` path segment, shared by e2ee and broadcast epochs - [E2EE](/quest/m1/e2ee/README.md) - TypeScript and Rust peers interoperate over encrypted broadcasts no relay can decrypt - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - each publish of a name gets a fresh `@` epoch, viewers follow the newest live one at once, and bare names still resolve on every version -- [Processor](/quest/m1/processor/README.md) - a customer-run worker publishes an on-demand contribution with scoped access +- [Processor](/quest/m1/processor/README.md) - a customer-run worker publishes an on-demand contribution under its own service prefix with scoped access - [#3056](/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - watch: the video decoder resets on a declared discontinuity - [#933](/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - the catalog rotation follows the live camera's orientation -- [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - @moq/publish gates the first catalog snapshot until every reserved track is described - [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the Opus producer follows its bandwidth grant through the settled `moq_mux::rate::Control` - [Ladder](/quest/m1/ladder/README.md) - a transcode ladder adapts to the uplink it publishes over, instead of encoding every live rung at its ceiling - [LOC duration marker](/quest/m1/loc-duration-marker.md) - LOC producers write the marker once released consumers skip it -- [#2278](/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md) - hang: expose the fixed catalog-root broadcast clock without synchronizing library playback to wall time +- [#2278](/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md) - hang: document reading the catalog-root clock and converting PTS to wall time, without synchronizing library playback to wall time - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent +- [Native audio quality](/quest/m1/audio-quality-native.md) - the browser lane's profiles, budgets, and metric schema run against `moq play` on a dummy device +- [fMP4 emsg](/quest/m1/emsg.md) - event messages survive fMP4 import, and the timed-metadata contract ID3, SCTE-35, and FLV script tags share is settled with them - [#2279](/quest/m1/2279-hang-typed-scte-35-ad-cue-signaling-carried-opaquely.md) - hang: SCTE-35 cues arrive immediately on an independent metadata track, optionally associated with a rendition - [Caption import](/quest/m1/captions-import.md) - fMP4 and MKV subtitle tracks import as text renditions instead of erroring or being dropped - [MSF caption roles](/quest/m1/captions-msf.md) - an MSF caption, subtitle, or sign-language track survives conversion to a hang catalog @@ -119,16 +160,19 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [SRT import stats](/quest/m1/srt-import-stats.md) - the SRT gateway reports the same per-stream counters instead of nothing - [Text availability](/quest/m1/text-schema.md) - a text track publishes its own coverage index instead of copying the media timeline - [ID3 catalog section](/quest/m1/id3.md) - timed ID3 as a first-class container-neutral catalog section -- [fMP4 emsg](/quest/m1/emsg.md) - event messages survive fMP4 import, and the timed-metadata contract ID3, SCTE-35, and FLV script tags share is settled with them - [FLV script tags](/quest/m1/flv-script.md) - onMetaData and AMF data messages survive RTMP and FLV import -- [Release size](/quest/m1/release-size.md) - the release scripts' LTO exports become the workspace release profile, and a nightly report shows what each moq-ffi build ships +- [Release profile](/quest/m1/release-profile.md) - every release build gets fat LTO, one codegen unit, and stripping from the workspace profile instead of three script exports +- [Size report](/quest/m1/size-report.md) - a nightly job reports every shipped artifact's size, native and JS, and alerts when one grows +- [Publish lazy file source](/quest/m1/publish-lazy-file.md) - a camera or screen `` stops downloading mediabunny's ~99 KB gzip +- [JS bundle trims](/quest/m1/js-bundle-trims.md) - minified worklets, no bowser, split pako, and lazy qmux and captions +- [Slim Docker images](/quest/m1/docker-slim.md) - images carry only the package's nix closure, not ~170 MiB of nixos/nix +- [Bindings size profile](/quest/m1/ffi-size-profile.md) - a benchmark decides whether the moq-ffi builds ship at opt-level "s", which halves the dylib +- [Go mirror delivery](/quest/m1/go-mirror-delivery.md) - the Go binding's staticlibs stop growing git history by ~210 MiB per release +- [Relay iroh opt-in](/quest/m1/relay-iroh-opt-in.md) - moq-relay drops iroh from its defaults and shipped builds, while moq-cli keeps it for P2P - [Dart on iOS](/quest/m1/dart-ios.md) - prove the shipped iOS native asset actually loads on a device, which no CI can -- [moq-c shutdown](/quest/m1/libmoq-shutdown.md) - OBS exits cleanly with the plugin loaded: a C ABI `moq_shutdown` stops the moq-c thread before the module is unloaded - [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - a Kotlin/JVM program exits cleanly whatever the moq-ffi runtime thread is doing, like Python does since #3766 - [Dart publish](/quest/m1/dart-publish.md) - the packages are built and dry-run clean but exist nowhere consumers can install from - [Dart codec parity](/quest/m1/dart-codecs.md) - Dart is the one binding that cannot originate media -- [moq-c fetch](/quest/m1/libmoq-fetch.md) - moq-c gains an additive cached-group fetch entry point -- [moq-mux on wasm32](/quest/m1/mux-wasm-target.md) - the crate's two wasm blockers are fixed and the target stays in the clippy lane - [#2850](/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - js/net: decode messages synchronously from buffered bytes and delete the publisher read-ahead queue - [Install moq](/quest/m1/moq-installer.md) - one command installs or upgrades the released CLI on macOS and Linux - [Install URL](/quest/m1/moq-install-url.md) - moq.dev serves the canonical installer at /install.sh diff --git a/quest/m1/announce-live-apps.md b/quest/m1/announce-live-apps.md new file mode 100644 index 0000000000..c6f9969516 --- /dev/null +++ b/quest/m1/announce-live-apps.md @@ -0,0 +1,36 @@ +# [M] Apps show "no broadcasts" from the live marker + +## Goal + +A browser page listing broadcasts shows an empty state once the relay has +said there are none, never a spinner that never resolves and never a false +empty state before the first session answered. The demo watch page and +`@moq/room` use `@moq/net`'s `live` marker, and `@moq/net` settles when an +origin stream opened before the first connection goes live. + +## Plan + +- #4261 (on `dev`) adds the `live` event, and #4266 the same marker in the + bindings. Open #4384 renames the announce events to Start/Update/End/Live; + follow its names if it lands first. The consumers it touches + (`demo/web/src/index.ts`, `js/room/src/room.ts`, `js/watch/src/broadcast.ts`, + `js/moq-boy`) skip it today. +- Page load: an origin stream opened before any session connects has no + session to wait on, so today it goes `live` at once and broadcasts arrive + after it. Settled: the reconnect loop (`js/net/src/connection/reload.ts`), + which already answers requests through `expect()`, holds the marker until + its first session lands `live` or its first dial gives up. An empty list + then means the relay said so or is unreachable, which a UI can tell apart. + Once a session is up, its own `live` ends the hold: every wire guarantees + one (ANNOUNCE_OK, ANNOUNCE_INIT, or the quiet-stream fallback). A peer that + accepts the announce stream and never answers is a peer bug, so no extra + timeout. + Check whether Rust's reconnecting client has the same gap. +- Apps: loading before `live`, an explicit empty state after it with nothing + announced, and an error state when the connection gives up. Libraries + expose the state as a signal; wording stays in the demo. +- Tests: an origin stream opened before connect is not `live` until the first + session is; a connection that cannot connect ends the wait. + +Public API: when `@moq/net` emits `live` changes; any state signal on +`@moq/room` or `@moq/watch` is additive. Lands on `dev` with #4261. Wire: none. diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 8c388a19bc..dd334aa40b 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -13,10 +13,19 @@ After normal catalog discovery, an HLS media playlist is generated by downloading only the timeline, never media objects. The catalog's root `archive` entry and the `moq-archive` object layout have -landed. The rest of the line is additive on top of them. +landed on main; the line may still reshape both. ## Plan +### Compatibility + +Decided (2026-09-28): the line may break the hang `archive` catalog entry and +the recording format in place on `main`, without a version bump or a `dev` +detour (for example the track-timeline rework in #4280). No archives exist +yet, so nothing recorded or published depends on either shape. This is an +exception to the main/dev rule for this line only; once a release ships +recordings, later format changes go through the entry's format version. + ### Landed The segment engine is in `rs/moq-mux/src/timeline.rs`: @@ -101,7 +110,7 @@ surface; [JavaScript FETCH](/quest/m1/js-fetch.md) supplies the missing browser IETF support before browser archive implementation. Transport codecs are owned by that prerequisite, not duplicated in archive storage. -## Quests +## Required - [Recording writer](/quest/m1/archive/writer.md) - feed the segmenter from a `broadcast::Consumer`, store each segment, then commit its record - [Recording reader](/quest/m1/archive/reader.md) - serve archived FETCH through a supplied `broadcast::Producer` @@ -110,11 +119,12 @@ owned by that prerequisite, not duplicated in archive storage. - [Browser archive](/quest/m1/archive/browser.md) - the same contract for browser-published broadcasts - [Offline archive HLS](/quest/m1/archive/hls.md) - render playlists from the archive timeline and fetch segment media lazily - [DVR rewind](/quest/m1/archive/dvr.md) - seek through a bounded archive and return to live playback +- [Enrollment flake](/quest/m1/archive/enrollment-flake.md) - the opening-snapshot test waits for real enrollment, not the `.info` file - [Archive proof](/quest/m1/archive/proof.md) - prove persistence ordering, selective reads, exact FETCH replay, and timeline-only HLS generation ## Related - [Catalog track identity](/quest/m2/catalog-tracks.md) - explore immutable definitions or explicit version binding independently of archives -- [wildcard](/quest/m1/wildcard/README.md) - catch-all routing exposes an archive at its stable replay path +- [wildcard](/quest/m0/wildcard/README.md) - catch-all routing exposes an archive at its stable replay path - [e2ee](/quest/m1/e2ee/README.md) - protected broadcasts are excluded initially diff --git a/quest/m1/archive/enrollment-flake.md b/quest/m1/archive/enrollment-flake.md new file mode 100644 index 0000000000..4395024703 --- /dev/null +++ b/quest/m1/archive/enrollment-flake.md @@ -0,0 +1,26 @@ +# [XS] The opening-snapshot test waits for real enrollment + +## Goal + +`archive::tests::an_opening_snapshot_records_every_rendition` in +`rs/moq-cli/src/archive.rs` passes under load. It fails with "writer closed" +because it treats a rendition's `.info` file as proof the export enrolled +that track, then finishes the tracks and the catalog; under load the writer +can still be subscribing when the tracks end. Seen while landing +[#4169](https://github.com/moq-dev/moq/pull/4169). + +## Plan + +- Wait on the signal enrollment actually produces (the subscription reaching + the publisher, or an explicit event from the exporter), not a file that + appears earlier. No longer timeout and no retry. +- If the exporter can genuinely lose a rendition that ends right after it + appears in the catalog, that is a bug in the exporter, not the test; fix it + there. + +Public API: none. Wire: none. + +## Related + +- [More tests hold up under load](/quest/m1/test-flakes-2.md) - the same + round of load-only failures on `main` diff --git a/quest/m1/audio-codecs/README.md b/quest/m1/audio-codecs/README.md index 372157036a..3d3c619be3 100644 --- a/quest/m1/audio-codecs/README.md +++ b/quest/m1/audio-codecs/README.md @@ -41,8 +41,9 @@ its own decode and encode quest so verification stays per host. The HE-AAC refusal and the PCE parse are defects in what ships today and are ready now. -## Quests +## Required +- [Named codecs](/quest/m1/audio-codecs/named-codecs.md) - `decode::Kind::Named` keeps the published codec names and picks backends internally; must land before the line merges - [HE-AAC refusal](/quest/m1/audio-codecs/he-aac-refusal.md) - implicit-SBR HE-AAC over TS is refused instead of half-decoded as the LC core - [AAC PCE](/quest/m1/audio-codecs/aac-pce.md) - a channel_config of 0 parses the program config element instead of guessing stereo - [Layout](/quest/m1/audio-codecs/layout.md) - the settled `Layout` carries up to 7.1 through decode, resample, playback, and the FFI @@ -55,7 +56,7 @@ ready now. ## Related - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - the OBS source and encoder adapters consume this through moq-ffi; #3498 narrowed OBS to what moq-audio decodes today -- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - Windows and Android verification needs a host; the Windows and macOS CI gates run nightly, not per PR +- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - Windows and Android verification needs a host; the Windows and macOS CI gates only compile - [Dart codec parity](/quest/m1/dart-codecs.md) - Dart gains these once it builds with the `audio` feature - [Media Foundation decode](/quest/m2/audio-decode-mediafoundation.md) - Windows decodes HE-AAC, multichannel AAC, and what else the MFTs offer - [Media Foundation encode](/quest/m2/audio-encode-mediafoundation.md) - Windows encodes AAC-LC diff --git a/quest/m1/audio-codecs/encode-audiotoolbox.md b/quest/m1/audio-codecs/encode-audiotoolbox.md index 0f86953835..401142b291 100644 --- a/quest/m1/audio-codecs/encode-audiotoolbox.md +++ b/quest/m1/audio-codecs/encode-audiotoolbox.md @@ -18,6 +18,11 @@ the encode seam as the platform candidate on macOS and iOS. - Regression: a stereo and a 5.1 encode round-trip through the AudioToolbox decoder and through symphonia (stereo only), with timestamps continuous across the priming. +- Decided in [#4183](https://github.com/moq-dev/moq/pull/4183): the uniffi + `MoqAudioEncoderOutput::frame_duration_us` default moves from 20000 to 0 + (the codec's own frame) with this quest, so `aac()` works without an + explicit 0. Changing a published binding default is a break, so that + change targets `dev`. ## Required diff --git a/quest/m1/audio-codecs/encode-backend.md b/quest/m1/audio-codecs/encode-backend.md index 20cb723a95..c1eb4a7b29 100644 --- a/quest/m1/audio-codecs/encode-backend.md +++ b/quest/m1/audio-codecs/encode-backend.md @@ -28,6 +28,12 @@ quest adds AAC through platform encoders; no software AAC dependency is selected backend's first packet. A backend that reports its own header (a magic cookie, `csd-0`, `MF_MT_USER_DATA`) must produce one equal to the synthesized ASC, asserted in its tests. +- The line branch's `aac-encode-refusals` quest makes `Config::encode` refuse + a channel count no channelConfiguration names instead of writing stereo, so + that synthesis is fallible and `Producer` refuses such a layout at + construction. JS matches: `@moq/hang`'s `audioSpecificConfig` throws for the + same counts (#4119, on the line). This absorbs the former m2 + aac-encode-refusal quest, a duplicate of that line quest. - Frame size is the codec's (1024 samples for AAC), so `frame_duration` is validated per codec rather than against the Opus table. - Bitrate updates go through the backend; one that cannot change rate diff --git a/quest/m1/audio-codecs/named-codecs.md b/quest/m1/audio-codecs/named-codecs.md new file mode 100644 index 0000000000..4bba08d514 --- /dev/null +++ b/quest/m1/audio-codecs/named-codecs.md @@ -0,0 +1,27 @@ +# [XS] Kind::Named keeps its codec names + +## Goal + +`moq_audio::decode::Kind::Named` accepts what published moq-audio 0.1.6 +accepts, the codec names (`"opus"`, `"aac"`, `"pcm"`), so the line can merge +to `main` without breaking callers. [#4131](https://github.com/moq-dev/moq/pull/4131) +switched it to backend names (`"libopus"`, `"symphonia"`, `"pcm"`) on the line +branch, which turns every existing `Named("opus")` or `Named("aac")` into +`Error::Unsupported` after an upgrade. This must land before the line PR +[#4081](https://github.com/moq-dev/moq/pull/4081) merges to `main`. + +## Plan + +- Decided: keep codec names; backends are picked internally. Which backend + decodes AAC (AudioToolbox, Media Foundation, symphonia) is the library's + choice through `Auto` and `Software`, not a string a caller has to know and + that changes per platform. This reverses #4131's reasoning on purpose: the + published contract wins over matching moq-video's backend naming. +- Make `encode::Kind` read the same way. It is unpublished, so drop `Named` + there or give it the same codec meaning, whichever leaves the two enums + mirrored; don't keep backend names on one side only. +- Tests that forced a backend by name move to a crate-private seam. +- Docs and the `Kind` doc comments list the codec names. + +Public API: `decode::Kind::Named` returns to its published meaning, so the +line stays additive on `main`. Wire: none. diff --git a/quest/m1/audio-quality-harness/native.md b/quest/m1/audio-quality-native.md similarity index 88% rename from quest/m1/audio-quality-harness/native.md rename to quest/m1/audio-quality-native.md index 07970c1e82..ff4d16ab81 100644 --- a/quest/m1/audio-quality-harness/native.md +++ b/quest/m1/audio-quality-native.md @@ -40,7 +40,11 @@ budget. Only then compare end-to-end totals, with the backend-dependent stages isolated: this lane deliberately accepts real device callback noise, so a difference in totals alone proves nothing about the estimator. +Standalone in m1 rather than a child of the m0 [Audio quality +harness](/quest/m0/audio-quality-harness/README.md) line (decided in the +2026-09-28 quest audit): nothing in m0 waits on it. The native jitter target +it grades is done on the jitter target line. + ## Required -- [Browser](/quest/m1/audio-quality-harness/browser.md) - defines the metric schema, the budget file, and the extracted shaper -- [Native jitter target](/quest/m0/audio-jitter-target/native.md) - the estimator this lane grades and compares against the browser; without it there is no target series and the budgets would be set against a playout path that holds nothing +- [Browser](/quest/m0/audio-quality-harness/browser.md) - defines the metric schema, the budget file, and the extracted shaper diff --git a/quest/m1/audio-ranked.md b/quest/m1/audio-ranked.md new file mode 100644 index 0000000000..8f4dbdc246 --- /dev/null +++ b/quest/m1/audio-ranked.md @@ -0,0 +1,14 @@ +# [S] Audio rendition pick + +## Goal + +An egress that carries one audio rendition (single-track FLV export and RTMP +play, WHEP) serves the best one it supports, not the first by track name. + +## Plan + +Video already shares `hang::catalog::Video::ranked`. Decide what "best" means +for audio (bitrate, then sample rate and channels are candidates) and add the +matching `Audio` ranking. RTMP's play check still checks the first audio +rendition by name; narrow it to what the client advertised, as video does. Test +with a catalog whose weaker rendition sorts first. diff --git a/quest/m1/audio-warmup.md b/quest/m1/audio-warmup.md index 926c6321a7..1b50e058d5 100644 --- a/quest/m1/audio-warmup.md +++ b/quest/m1/audio-warmup.md @@ -15,14 +15,16 @@ frames decode independently and set nothing; HE-AAC is out of scope. publish `warmup` for Opus; `js/publish` does the same for its Opus track. - `rs/moq-audio/src/decode` already trims Opus `pre_skip` at stream start; the warmup trim is the same mechanism keyed on the container consumer's - non-continuous signal, and the subscription's maximum age grows by `warmup` - as the video consumer quest does. `js/watch` audio mirrors it. + non-continuous signal (added in Rust by the open-GOP quest), and the + subscription's maximum age grows by `warmup` as the video consumer quest + does. `js/watch` audio mirrors it. - Tests in both languages: a mid-stream join discards exactly the warmup span and a continuous listener loses nothing. ## Required - [Catalog warmup](/quest/m1/catalog-warmup.md) - the field this reads and writes +- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - adds the Rust non-continuous signal the trim keys on ## Related diff --git a/quest/m1/auth-outage-clock.md b/quest/m1/auth-outage-clock.md new file mode 100644 index 0000000000..43c923a8b3 --- /dev/null +++ b/quest/m1/auth-outage-clock.md @@ -0,0 +1,63 @@ +# [M] Auth outage tests on a paused clock + +## Goal + +The moq-relay and moq-auth outage tests run on tokio's paused clock again and +assert both bounds: a session (or grant) survives an auth outage until its +`expires`, and closes at `expires`, not later. No wall-clock sleeps, no +widened timeouts, and no dependence on how fast the OS delivers loopback. + +## Plan + +- The tests: `an_outage_keeps_the_session_until_expires` in + `rs/moq-relay/tests/auth_lifetime.rs` + ([#4244](https://github.com/moq-dev/moq/pull/4244)) and + `an_outage_keeps_the_grant_until_expires` in `rs/moq-auth/src/client.rs` + ([#4291](https://github.com/moq-dev/moq/pull/4291)). Both moved to the real + clock because a paused clock auto-advances while the runtime waits on a + real socket, so a virtual timer fired before macOS delivered loopback. That + swapped one violation of "unit tests mock time" for another, and #4244 + dropped the upper bound. Read both PR descriptions: they list what was + tried and why it failed (restoring a listener probe, pausing after setup, + waiting on the log). +- The race is real sockets under virtual time, so fix it by taking the + sockets out of these tests. Look at what the codebase already offers + before building anything: `rs/moq-net/tests/support/mock.rs` (an in-memory + session pair), `moq_relay::auth::Auth::embedded` with its `Admissions` + (decides leases in-process), and the lease driver in moq-auth, which could + be exercised against an in-process answer source instead of HTTP. If the + relay's `Connection` or moq-auth's `Client` cannot take such a transport, + prefer the small seam that lets them over a test-only shim. +- Decide where each assertion belongs. The outage semantics (a 503 keeps the + grant until `expires`) are moq-auth's; the relay test may only need to show + that a lease reaching `expires` closes the session as `Expired` and reports + `end`. Don't keep two tests proving the same thing. +- Measure against tokio's clock, not `SystemTime`: the grant still carries a + wall-clock `expires`, so pin how it maps onto the paused clock. +- Other paused-clock tests touch real sockets and would share the hazard + once a timeout lands on their path. #4291's audit named + `a_grant_within_clock_skew_stays_live` (moq-auth) and + `fixed_addresses_keep_tls_name_and_request_host` (moq-tokio websocket); + moq-auth's `clock_server` helper exists only to keep axum on the paused + clock. Move those onto the same seam if it is cheap. +- Prove it: loop the tests with every core loaded, on macOS if available, + and mutate the deadline both ways (close early, close late) to see each + bound fail. + +Public API: none unless a transport seam is needed; report it if so. Wire: +none. + +## Related + +- [More tests under load](/quest/m1/test-flakes-2.md) - the same rule + applied to other load-only failures +- [moq-shaper virtual time](/quest/m1/shaper-virtual-time.md) - the same + paused-clock-versus-real-socket fight in moq-shaper +- [#4280](https://github.com/moq-dev/moq/pull/4280) - moq-archive and + moq-hls tests poll with real-clock sleeps, on the archive track-timeline + line +- [#4281](https://github.com/moq-dev/moq/pull/4281) - OBS `WaitFor` polling, + on the C++ line +- [Nightly 2026-09-26](https://github.com/moq-dev/moq/actions/runs/36240326747/job/108399481809) - + the macOS relay tarball job failed this test with "publisher connect + timeout", before #4244 landed diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 9696c7b9ce..e167ab6416 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -87,7 +87,7 @@ Everything here is additive: `Session::auth()` is new, the relay derives the grant from the origin handles it already scopes, and AUTH is added to the existing lite-06 ALPN. -## Quests +## Required - [Lite stream](/quest/m1/auth/lite.md) - both sides of a lite-06 session exchange grants over AUTH streams, exposed as `Session::auth()`, and an @@ -96,6 +96,8 @@ 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 +- [AUTH_OK preflight](/quest/m1/auth/auth-ok-preflight.md) - an unencodable IETF grant answers NOT_SUPPORTED with nothing written, as JS already does +- [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends - [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 @@ -106,6 +108,8 @@ existing lite-06 ALPN. grant does not, and REQUEST_UPDATE refreshes it - [moq-transport](/quest/m1/auth/moq-transport.md) - the same exchange as a setup-option extension on draft-17+, specified in a new draft +- [Expired token error](/quest/m1/auth/expired-error.md) - an expired token + reports `Error::Expired`, not `Unauthorized`, in Rust, JS, and the bindings - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every binding through moq-ffi and moq-c - [Token in band](/quest/m1/auth/token-in-band.md) - the credential can leave diff --git a/quest/m1/auth/auth-ok-preflight.md b/quest/m1/auth/auth-ok-preflight.md new file mode 100644 index 0000000000..eecead51ac --- /dev/null +++ b/quest/m1/auth/auth-ok-preflight.md @@ -0,0 +1,24 @@ +# [XS] An AUTH_OK that cannot be sent is refused, not half-written + +## Goal + +When the Rust IETF acceptor's grant cannot be encoded as one AUTH_OK for any +reason, it answers `AUTH_ERROR { NOT_SUPPORTED }` and nothing of the AUTH_OK +reaches the wire, the same as JS. Today the preflight in +`rs/moq-net/src/ietf/auth.rs` (`serve_issue`) catches only +`EncodeError::Unsupported`, so a prefix grant too large for the `u16` message +size falls through to `encode_message`, which fails mid-write and ends the +stream with a transport error instead of a refusal the presenter can read. +Found in review of [#4124](https://github.com/moq-dev/moq/pull/4124). + +## Plan + +- Refuse on any sizing error, not only `Unsupported`, including the message + size ceiling the writer enforces. Never trim or widen the grant to make it + fit: withholding it is the only safe answer. +- Check whether the lite AUTH_OK path has the same gap and fix both if so. +- Regression test mirroring JS's "a grant too large for one message is + refused before anything is written": the presenter sees `Unsupported` and + no AUTH_OK bytes were written. + +Public API: none. Wire: none. diff --git a/quest/m1/auth/error-codes.md b/quest/m1/auth/error-codes.md new file mode 100644 index 0000000000..76dfcbe1f5 --- /dev/null +++ b/quest/m1/auth/error-codes.md @@ -0,0 +1,36 @@ +# [S] AUTH endings are exact on both sides + +## Goal + +The loose ends Codex left on [#4062](https://github.com/moq-dev/moq/pull/4062) +are closed, so every way an AUTH stream ends reports what actually happened: + +- A lite AUTH_ERROR whose code does not fit a `u32` is a protocol violation. + Today `rs/moq-net/src/lite/session.rs` saturates it with + `u32::try_from(refused.code).unwrap_or(u32::MAX)`, so distinct peer codes + collapse into one and the app sees a code the peer never sent. +- JS `AuthSession.close()` in `js/net/src/auth_session.ts` clears its tokens + but never recomputes the union, so a retained `auth.grant` keeps reporting + a live grant after the session closed. Rust already clears it. +- A JS presenter whose acceptor ends the grant with a clean FIN exits the read + loop without closing its own write half, unlike the AUTH_ERROR branch, so + the acceptor's `Issued.closed` stays pending until the session ends. +- A Rust `Issued::closed()` never resolves if the session drops the task + serving that token (`AuthServe` in the lite publisher, `Serve` in + `ietf/auth.rs`): only the task's own completion records `issue.peer`. + +## Plan + +- Fail loud on the unrepresentable code rather than widen the error type: + the codes AUTH_ERROR carries are session codes, which are `u32` everywhere + else. +- Settle a dropped serve task from a drop path (a guard that records the + session's error on `Issue` and wakes waiters), so no exit path can forget + it. +- One regression test per item, each failing without its fix. + +The fifth deferred item, a grant recheck after async origin resolution, +belongs to [Origin narrowing](/quest/m1/auth/narrowing.md) with the other +watcher races. + +Public API: none. Wire: none. diff --git a/quest/m2/auth-expired-error.md b/quest/m1/auth/expired-error.md similarity index 61% rename from quest/m2/auth-expired-error.md rename to quest/m1/auth/expired-error.md index 013ab441fe..cdd88e4446 100644 --- a/quest/m2/auth-expired-error.md +++ b/quest/m1/auth/expired-error.md @@ -14,6 +14,11 @@ refusal as final. `EXPIRED_AUTH_TOKEN`, and back again when refusing. - Carry it through moq-ffi's error mapping and each wrapper. +Moved from m2 into the auth line: relay tokens refuse an expired +token with `AUTH_ERROR { Expired }`, so without this moq-net cannot tell it +apart from `Unauthorized`. + ## Required -- [In-band auth](/quest/m1/auth/README.md) - the AUTH streams that carry these codes +- [Lite stream](/quest/m1/auth/lite.md) - the lite AUTH streams that carry `Expired` +- [moq-transport](/quest/m1/auth/moq-transport.md) - the extension that carries `EXPIRED_AUTH_TOKEN` diff --git a/quest/m1/auth/narrowing.md b/quest/m1/auth/narrowing.md index adcf5db209..fdac84e474 100644 --- a/quest/m1/auth/narrowing.md +++ b/quest/m1/auth/narrowing.md @@ -29,6 +29,17 @@ A narrowing always succeeds. A changed root still closes the session. 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). +- Close the revocation races Codex found on + [#4179](https://github.com/moq-dev/moq/pull/4179), which the same watcher + mechanism owns: an in-flight lite FETCH keeps serving after its path is + revoked (Rust and JS check only at accept, and Rust's dropped fetches reset + `CANCELLED`, not `UNAUTHORIZED`); a Rust lite subscription still in + `Establish` when the grant shrinks is dropped with `CANCELLED` instead of + aborted `UNAUTHORIZED`; and the JS lite publisher and subscriber arm their + grant watchers only after an await (`demand()`, `#openSubscribe`), and + `Getter.subscribe` does not replay, so a shrink during setup is missed for + good. Every request holds one watcher from its first check to its end, and + rechecks when the watcher is armed. - 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 diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index c6125f8295..fa93750186 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -16,8 +16,8 @@ on the unknown key, and the legacy drafts silently ignore it. ## Plan -- Decode with the [Setup token](/quest/m1/setup-token.md) structure and - rules: `USE_VALUE` yields the token, `REGISTER` is a value since we +- Decode with the SETUP option's structure and rules + (`rs/moq-net/src/ietf/token.rs`, `js/net/src/ietf/token.ts`): `USE_VALUE` yields the token, `REGISTER` is a value since we advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, and `DELETE` or `USE_ALIAS` closes with `PROTOCOL_VIOLATION`. Both decoder families change: the strict `decode_params!` path, which rejects the key today, and the generic KVP @@ -41,29 +41,35 @@ on the unknown key, and the legacy drafts silently ignore it. 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/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 - same bytes; the lease is dropped with the request. The request is resolved - against the origin with the path checked against that lease's grant, not - through the session's scoped origin handle. +- Relay: each such request gets its own lease from a per-request call on + `moq_auth::Client`, not the `Client::attach` that [Relay + tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the + connection's id, and the auth server treats that as one more grant on the + session that never POSTs `end`, so a request token would widen the whole + connection for its life. The per-request call carries the token in + `moq_auth::Request.token` with its kind (a CAT reaches the CAT verifier, + not the JWT one) and the request's path, is never counted as a session + grant, and ends when the request ends. Two requests carrying the same bytes + get two leases. The request is resolved against the origin with the path + checked against that lease's grant, not through the session's scoped + origin handle. Name the call while implementing. - `js/net` mirrors the decode and the default refusal. - Docs: `doc/bin/relay/auth.md` states the order (session grant, then the request's token, then `UNAUTHORIZED`), that a request token covers only its request, and how a peer refreshes with REQUEST_UPDATE. - Tests: a SUBSCRIBE outside the session grant succeeds with a covering token and is refused `UNAUTHORIZED` without one; its token grants nothing to a - second SUBSCRIBE; a request inside the session grant never calls the + second SUBSCRIBE, and through the relay the auth server never counts it as + a session grant and sees its lease end with the request; a request inside the session grant never calls the verifier; a REQUEST_UPDATE token keeps a subscription alive past the old token's expiry; an expired request token ends only that request; with no consumer a token-bearing request is refused `Unsupported`; one legacy and one strict draft, Rust and JS. Public API: additive on `moq_net::auth::Request` (the request it belongs -to). Wire: none new; the parameter already exists in every supported draft. +to) and on `moq_auth::Client` (the per-request lease). Wire: none new; the parameter already exists in every supported draft. ## Required -- [Setup token](/quest/m1/setup-token.md) - supplies the token decoder -- [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the per-token - lease and `Client::attach` path each request uses +- [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the lease + revalidation the per-request lease reuses diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index a5e45dacb4..ccd2e32874 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -43,8 +43,11 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. stream; every other configured token gets its own AUTH stream on an AUTH-capable session, so no token is ever granted twice. On moq-transport the first token also rides the AUTHORIZATION TOKEN setup option - (`ParameterBytes::AuthorizationToken`, `USE_VALUE`, token type 0), which - scopes at accept the way the URL does. + (`ietf::token::into_setup`, `USE_VALUE`, token type 0), which + scopes at accept the way the URL does. While the URL also carries it, the + auth server sees the same credential twice: `moq auth serve` admits a SETUP + token equal to the `?jwt=` value and refuses two different ones + (`serve::Refusal::TwoTokens`). Never send different values in the two places. - The relay admits on the URL, then widens. An anonymous connection today is admitted with the public grant when one is configured and refused otherwise; with this quest a connection with no URL credential and no @@ -62,7 +65,9 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. - Tests: a client with a configured token against an AUTH-capable relay is scoped exactly as the URL variant and the URL carries the token only while an old version is offered; the same client against a lite-05 relay still - authenticates through the URL; two configured tokens union; a client with + authenticates through the URL; a moq-transport client offering a draft + without AUTH, carrying the token in both the URL and the setup option, is + admitted by `moq auth serve`; two configured tokens union; a client with in-band tokens only and no public grant is admitted, and one that presents nothing is refused at the deadline; the cross-language harness runs with tokens configured. @@ -77,5 +82,3 @@ Additive. token setters sit beside - [moq-transport](/quest/m1/auth/moq-transport.md) - supplies the IETF AUTH exchange the setup-option token pairs with -- [Setup token](/quest/m1/setup-token.md) - supplies `setup::Token` and the - setup-option encoder diff --git a/quest/m1/bbr-ack-cleanup.md b/quest/m1/bbr-ack-cleanup.md index 02ad06e0f1..3439bf801e 100644 --- a/quest/m1/bbr-ack-cleanup.md +++ b/quest/m1/bbr-ack-cleanup.md @@ -31,8 +31,8 @@ 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. +sampling and congestion policies unchanged; loss-sample repair belongs to +its own quest. Extend existing tests for ordered, reordered, duplicate, and batched ACKs; loss and spurious loss; expiry; overlapping packet numbers in different @@ -50,12 +50,11 @@ 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 +completing this quest. Do not wait for the broader QUIC stack release. 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 +- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - preserve packet metadata needed by the separate loss-sample repair; both edit `bbr3/mod.rs`, so sequence them +- [BBR starvation edges](/quest/m1/quic/bbr-app-limited-edges.md) - also edits `bbr3/mod.rs`; one owner there at a time - [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 deleted file mode 100644 index d085ba2d8c..0000000000 --- a/quest/m1/bbr-classic-ecn.md +++ /dev/null @@ -1,61 +0,0 @@ -# [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 deleted file mode 100644 index 480cbad830..0000000000 --- a/quest/m1/bbr-idle-burst.md +++ /dev/null @@ -1,31 +0,0 @@ -# [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-ci.md b/quest/m1/bench-ci.md index b317185d53..4df11aa246 100644 --- a/quest/m1/bench-ci.md +++ b/quest/m1/bench-ci.md @@ -23,8 +23,8 @@ a GitHub App: - PR: one job builds base and head on the same runner and runs only the selected targets, saving and comparing Criterion baselines. Selection is the - changed crates plus their dependents, from the impact map that - [Thin justfiles](/quest/m1/tooling/justfiles.md) lands. No selected bench + changed crates plus their dependents, from the impact map that the + [tooling line](/quest/m1/tooling/README.md) lands. No selected bench means no job. Extend `bench/run.sh` with a Criterion-only, crate-scoped mode behind a recipe instead of writing a second runner. Hosted runners vary by about 3%, so the comment highlights only changes Criterion calls @@ -45,7 +45,7 @@ a GitHub App: ## Required -- [Thin justfiles](/quest/m1/tooling/justfiles.md) - owns the diff-to-crate impact map the PR job reuses +- [Tooling](/quest/m1/tooling/README.md) - owns the diff-to-crate impact map the PR job reuses ## Related diff --git a/quest/m1/binding-surface.md b/quest/m1/binding-surface.md index c5041402fd..1d080fded3 100644 --- a/quest/m1/binding-surface.md +++ b/quest/m1/binding-surface.md @@ -2,7 +2,7 @@ ## Goal -moq-ffi, moq-c, and every wrapper (Python, Go, Swift, Kotlin, Dart) expose +moq-ffi and every wrapper (Python, Go, Swift, Kotlin, Dart) expose `decode::Options::delay` and `decode::Consumer::delay()` so applications can configure and observe audio playout delay. @@ -11,9 +11,9 @@ configure and observe audio playout delay. - 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. -- Add the moq-c implementation and tests in `rs/moq-c`, 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. +- moq-ffi only, not the hand-written moq-c: the [generated C](/quest/m1/c/README.md) and + C++ bindings inherit it from moq-ffi. +- Update `doc/lib/{py,swift,kt,go,dart}` in the same PR. - Test configuration and observed delay in every wrapper that has tests. ## Required diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m1/broadcast-epoch/README.md index 587a892f85..2d72e47fac 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m1/broadcast-epoch/README.md @@ -42,7 +42,7 @@ Decided: prefix route. Document this rather than promise it works. - Derived output lives under the epoch it came from (`pid/foo.hang/@e/transcode.pro`), so nested epochs must parse. This moves - the [wildcard](/quest/m1/wildcard/README.md) line's derived-output example + the [wildcard](/quest/m0/wildcard/README.md) line's derived-output example down one segment, and its suffix patterns still match. This README owns: @@ -55,14 +55,11 @@ This README owns: consume behavior, takeover and fallback, bare-path resolution, and the prefix-route opt-out. -## Quests +## Required +- [Epoch primitive](/quest/m1/epoch.md) - the shared `Epoch` type and path split - [Origin](/quest/m1/broadcast-epoch/origin.md) - moq-net publish mints an epoch, consumers follow the newest live one, and bare requests resolve to it on every version - [Apps](/quest/m1/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web publish under epochs and play bare names - [Gateways](/quest/m1/broadcast-epoch/gateways.md) - RTMP, SRT, and WHIP ingest mint an epoch per incoming connection, so an encoder reconnect is a clean takeover - [Bindings](/quest/m1/broadcast-epoch/bindings.md) - moq-ffi, moq-c, and every wrapper expose the epoch and inherit the default - [GStreamer and OBS](/quest/m1/broadcast-epoch/gst-obs.md) - moqsink and the OBS plugin publish each run under a fresh epoch - -## Required - -- [Epoch primitive](/quest/m1/epoch.md) - the shared `Epoch` type and path split diff --git a/quest/m1/browser-benchmarks.md b/quest/m1/browser-benchmarks.md index 06043fac8d..b8ed38d6ef 100644 --- a/quest/m1/browser-benchmarks.md +++ b/quest/m1/browser-benchmarks.md @@ -2,21 +2,24 @@ ## Goal -A reproducible browser suite measures JS transport and media costs that the Rust -Criterion targets and native relay load generator do not exercise. +A reproducible real-browser suite measures JS transport and media costs that +the Rust Criterion targets, the native relay load generator, and the Bun +microbenchmarks do not exercise. ## Plan -`bench/run.sh::criterion_targets` discovers Cargo targets only. Existing JS unit -tests validate behavior, and `test/wasm` validates browser interop, but neither -provides a repeatable JS performance comparison. Reuse the existing relay/browser -harness pieces and add a focused recipe with artifacts under the benchmark -conventions. Microbenchmarks may run in Bun; browser conclusions must come from -an identified browser version on a real WebTransport connection. +The JS microbenchmarks already exist: the four Bun sweeps in `js/net/bench` +(`broadcasts`, `reader`, `frames`, `track`) run nightly and cover the origin +map, fragmented `Reader` reads, group frame decode, and track retention. This +quest is only the real-browser half: conclusions come from an identified +browser version on a real WebTransport connection. `test/wasm` validates +browser interop but measures nothing. Reuse the existing relay/browser harness +pieces and add a focused recipe with artifacts under the benchmark conventions. -- Cover `js/net/src/stream.ts` with buffered controls, fragmented varints, and - payloads from small audio through large keyframes. Sweep chunk sizes and record - CPU, wall time, allocation volume, GC pauses, and bytes copied where measurable. +- Measure the `js/net/src/stream.ts` path in the browser over WebTransport, + with payloads from small audio through large keyframes, recording CPU, wall + time, allocation volume, GC pauses, and bytes copied where measurable. Add a + Bun sweep only for a cost the browser run finds and the four miss. - Cover CMAF encode/decode with fixed audio/video fixtures and multiple samples. Keep fixture generation and relay startup outside timed intervals. - Add publish/watch scenarios measuring delivered/decoded/presented frames, diff --git a/quest/m1/c/README.md b/quest/m1/c/README.md new file mode 100644 index 0000000000..92aec50f5d --- /dev/null +++ b/quest/m1/c/README.md @@ -0,0 +1,53 @@ +# Generated C bindings + +## Goal + +C programs use moq through a C API generated from moq-ffi, shipped as the +`moq-c` package with CMake target `moq::c`, so the hand-written libmoq ABI is +deleted and C tracks every moq-ffi change the way Go, Swift, Kotlin, Python, +Dart, and C++ already do. The C API shape follows moq-ffi and breaks C users +once. The C++ package and the OBS plugin are out of scope; they already sit on +moq-ffi through [C++ through moq-ffi](/quest/m1/cpp/README.md). + +## Plan + +Decided: + +- The generator is a C backend in the `kixelated/uniffi-bindgen-cpp` fork, + beside the C++ one, so both share its parsing, async, and error plumbing and + one pinned tool covers both. No mature upstream C generator exists; uniffi + itself only emits the low-level scaffolding header. +- Async calls take a completion callback and user data and return a task; + freeing the task cancels the call. Callbacks run on moq's dispatcher thread + by default, or on the host's own loop through `moq_set_dispatcher` and + `moq_work_run`, the C form of `moq::set_executor`. Streams are repeated calls. + This removes the trampolines and lock-order rules libmoq's single callback + thread forced on hosts like OBS. +- Errors, records, and names mirror the C++ package: calls return `moq_error *` + (NULL on success) with `moq_error_message()`, results come through + out-params, records are owned structs with `moq__free`, and names are + the uniffi names in snake case. +- The line targets `dev`: #4288 already renamed the hand-written crate to + `moq-c` (`rs/moq-c`) there, and the generated package takes over that name + and `moq::c` target, so C users migrate once. Its first release is 0.8.0, a + minor bump over the hand-written 0.7.x. +- Docs change inline: the consumer quest rewrites `doc/lib/c`, and retirement + adds an upgrade note. No separate guide. + +The line owns the end-to-end check: every `doc/lib/c` sample and the C interop +client build and run against the released 0.8.0 archive, not only in-tree. + +The hand-written crate gets no more feature work: its shutdown, CMake library, +and fetch quests were abandoned for this line, and hidden is done on dev. + +## Required + +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the generator fork, package recipe, and OBS move this line builds on +- [C backend](/quest/m1/c/backend.md) - the fork emits an ergonomic C header and implementation from moq-ffi, with its own tests +- [moq-c package](/quest/m1/c/package.md) - the generated header ships as `moq-c` 0.8.0 with `moq::c`, pkg-config, and a release workflow +- [C consumers](/quest/m1/c/consumers.md) - the C interop client and `doc/lib/c` samples move onto the generated API +- [Retire libmoq](/quest/m1/c/retire.md) - the hand-written crate is deleted after its final release points at the generated package + +## Related + +- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes moq-ffi, which the generated C then follows for free diff --git a/quest/m1/c/backend.md b/quest/m1/c/backend.md new file mode 100644 index 0000000000..404f5e0f42 --- /dev/null +++ b/quest/m1/c/backend.md @@ -0,0 +1,25 @@ +# [L] C backend in the bindings generator + +## Goal + +`uniffi-bindgen-cpp` (the `kixelated` fork) gains a C backend that turns +moq-ffi's uniffi metadata into an ergonomic C header and implementation, in the +shape the [line](/quest/m1/c/README.md) decided: completion callbacks with a +cancelling task handle, a pluggable dispatcher, `moq_error *` returns with +out-params, owned record structs with free functions, and snake-case uniffi +names. + +## Plan + +- Build on the fork's C++ backend: it already walks the metadata, serializes + records and errors through `RustBuffer`, and drives `rust_future_poll` on a + dispatcher. The C backend emits the same calls behind a C surface, so the + generated implementation may itself be C++ compiled into the package, as long + as the public header is plain C (C99 or C11; say which). +- Cover every construct moq-ffi uses today: objects (handles), records, enums, + the one error type, async methods, and byte buffers. moq-ffi has no callback + interfaces; refuse them loudly rather than emit something half-working. +- The fork's test suite gains C fixtures for each construct, including + cancelling a pending task and running callbacks through a custom dispatcher. +- Offer the backend to the LiveKit or NordSecurity upstream once its shape + settles, or record why not. diff --git a/quest/m1/c/consumers.md b/quest/m1/c/consumers.md new file mode 100644 index 0000000000..79ec900659 --- /dev/null +++ b/quest/m1/c/consumers.md @@ -0,0 +1,18 @@ +# [S] C consumers on the generated API + +## Goal + +The C interop client (`test/interop/clients/c`) and the `doc/lib/c` samples use +the generated `moq-c` API and pass `just test interop --all` and the doc-sample +build. Nothing in the repository calls the hand-written ABI any more. + +## Plan + +- Port the interop client first; it is the smallest real consumer and proves + the callback and dispatcher shape end to end. +- Rewrite `doc/lib/c` around the generated API, including a sample that runs + callbacks on the host's own loop through `moq_set_dispatcher`. + +## Required + +- [moq-c package](/quest/m1/c/package.md) - the package these consumers link diff --git a/quest/m1/c/package.md b/quest/m1/c/package.md new file mode 100644 index 0000000000..3a8a602f62 --- /dev/null +++ b/quest/m1/c/package.md @@ -0,0 +1,28 @@ +# [M] moq-c package from the generated C + +## Goal + +The generated C ships as the `moq-c` package, version 0.8.0: a release archive +with the header, the moq-ffi staticlib, a CMake config exporting `moq::c`, and +`moq-c.pc`, built the same way `cpp/moq` builds the C++ package. It replaces the +hand-written `rs/moq-c` crate's artifacts (renamed from libmoq on dev by #4288) +under the same names. + +## Plan + +- Mirror `cpp/moq`: CMake runs `cargo build -p moq-ffi`, then the generator's C + backend, and installs the result; a probe test compiles and links from the + installed package through both `find_package(moq-c)` and pkg-config. +- The installed CMake config honors `CMAKE_INSTALL_LIBDIR`, so a `lib64` + distro gets a working `find_package`; the hand-written crate's config + hardcodes `/lib`. +- `doc/index.md` says the C library ships static and shared; say what the + package actually ships. +- A release workflow and nightly/CI jobs follow the C++ package's, pinned to the + fork tag that carries the C backend. +- Every Cross-Package Sync row that names libmoq for moq-ffi changes points at + the generated package instead; update `AGENTS.md` in this quest. + +## Required + +- [C backend](/quest/m1/c/backend.md) - the generator output this packages diff --git a/quest/m1/c/retire.md b/quest/m1/c/retire.md new file mode 100644 index 0000000000..5a821f75e3 --- /dev/null +++ b/quest/m1/c/retire.md @@ -0,0 +1,19 @@ +# [S] Retire the hand-written libmoq + +## Goal + +The hand-written C crate is gone: its last release points users at the +generated `moq-c` 0.8.0, and the source, workflows, and docs that only served +it are deleted. `doc/setup/upgrade.md` tells C users how to move. + +## Plan + +- #4288 renamed libmoq to `moq-c` on dev and left a code-free `rs/libmoq` stub + whose last release points at `moq-c`; dev's + `quest/m1/libmoq-retire.md` deletes that stub. This quest retires the + hand-written `rs/moq-c` itself, so fold in or delete that quest, whichever + is still open. + +## Required + +- [C consumers](/quest/m1/c/consumers.md) - nothing in the repository still calls the hand-written ABI diff --git a/quest/m1/cache-expiry-growth.md b/quest/m1/cache-expiry-growth.md index fc2ea10f9d..0aafd2b2c1 100644 --- a/quest/m1/cache-expiry-growth.md +++ b/quest/m1/cache-expiry-growth.md @@ -18,7 +18,12 @@ cached groups, not a leak elsewhere. An earlier run saw growth on IETF only, but lite was then 30x slower per round, so it wrote far fewer groups; compare by groups written, not by wall time. -Reproduce with a focused test first. Unexpired groups at higher throughput, +#4378 (after these runs) fixed one candidate cause: an ended track's latest +group was exempt from idle expiry and the pool sweep never reached an ended +track, so stale consumers pinned its groups. Re-measure on current `main` +first; if RSS now plateaus, delete this quest. + +Otherwise reproduce with a focused test. Unexpired groups at higher throughput, expiry not running on some path, or groups held outside the pool's accounting would each explain it. Fix what is actually wrong. Consider whether an unbounded default is the right default for an origin at all. diff --git a/quest/m1/cache-wall-eviction.md b/quest/m1/cache-wall-eviction.md new file mode 100644 index 0000000000..083a2857e5 --- /dev/null +++ b/quest/m1/cache-wall-eviction.md @@ -0,0 +1,45 @@ +# [S] Plan: wall-clock age-out in the track cache + +## Goal + +A benchmark decides whether a track's cache ages groups out without waiting +for a write, on `max(wall, pts)` like other time decisions in moq-net, or +stays the one write-driven exception. The result is an implementation quest +or a recorded reason to keep the exception. + +## Plan + +Settled scope: a track's `max_age` retention aging groups out on a timer +instead of only on a write. The pool's idle expiry is out of scope. + +- Today a track's `max_age` is media time and is applied only when the track + writes: a group ages out when a later one starts (`is_stale` and the expiry + scans in `rs/moq-net/src/model/track.rs`). A track that stops writing keeps + groups past `max_age` until the pool's idle expiry (`Pool::gc`, driven by + the origin driver without a write) or byte pressure reclaims them. + `max_age_does_not_drive_wall_eviction` pins that behaviour. +- JS already does the opposite: `#prune` in `js/net/src/track.ts` evicts a + group once it has been idle on the wall clock (`performance.now`) past + `maxAge`, on its own timer, and `js/net/bench/track.ts` benches the publish + cost against the retained window. So `max_age` means different things per + language today. The decision settles both: either Rust gains a wall term or + JS moves to media time, and the loser's tests and docs change with it. +- Prototype the alternative behind a bench-only switch: each track keeps a + deadline for its oldest group on `max(wall elapsed, pts)`, armed on the + timers the origin driver already runs, and evicts on expiry. +- Bench in `rs/moq-net/benches/track.rs`, swept over tracks (1 to 10k) and + cached groups per track (1 to 1k): write-path cost, timer cost per driver + pass, and retained memory for a population of idle tracks. A cost that + grows with the table should show as a slope. +- Weigh the semantics too: `max_age` is media time on purpose, so a congestion + stall cannot age content out (`track::Info::max_age`). A wall term changes + that for a stalled but live publisher. +- Record the numbers and the decision in the PR, then rewrite this quest into + the implementation or delete it. + +Public API: none from the plan. Wire: none. + +## Related + +- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - relay memory past the expiry window, in the same cache +- [Cache shard](/quest/m1/perf/cache-shard.md) - the pool's shared counters under many workers diff --git a/quest/m1/captions-msf.md b/quest/m1/captions-msf.md index eb68c70696..5079a87ee4 100644 --- a/quest/m1/captions-msf.md +++ b/quest/m1/captions-msf.md @@ -2,16 +2,18 @@ ## Goal -An MSF catalog's caption, subtitle, and sign-language tracks survive -conversion into a hang catalog. Today they are dropped with a warning, so a +An MSF catalog's caption and subtitle tracks survive conversion into a hang +catalog. Today they are dropped with a warning, so a round trip through MSF silently loses every caption. ## Plan `from_msf` skips any track "with no role, with an unsupported role (caption, subtitle, sign language, audio description, custom roles)". Now that the hang -`text` section exists with a `TextRole` deliberately mirroring the MSF role -registry, three of those map straight across and stop being unsupported. +`text` section exists with a `TextRole` borrowing the MSF role names, two of +those map straight across and stop being unsupported: `TextRole` has only +`Subtitle`, `Caption`, and `Unknown(String)`, which preserves any other role +verbatim. Map the roles, and derive the rest of `TextConfig` from what MSF carries: language onto `lang`, the display name onto `label`, and the packaging onto @@ -23,7 +25,9 @@ garbage. Prove the round trip in both directions, so an MSF catalog converted to hang and back keeps its caption tracks, roles, and languages. Audio description is deliberately left unmapped: it is an audio rendition with a role, not timed -text, and mapping it into `text` would be wrong. +text, and mapping it into `text` would be wrong. Sign language is likewise a +video rendition, so it stays unmapped too. A custom role maps to +`TextRole::Unknown` only when the track's codec is a text format. Per cross-package sync, mirror any schema movement in `js/msf`. diff --git a/quest/m1/capture-control.md b/quest/m1/capture-control.md new file mode 100644 index 0000000000..612839d335 --- /dev/null +++ b/quest/m1/capture-control.md @@ -0,0 +1,50 @@ +# [M] Capture Control: settled name, loud cut, prompt cancel, catalog clock + +## Goal + +The capture handles from [#4184](https://github.com/moq-dev/moq/pull/4184), +on `dev` only, get their final shape before release: + +- `encode::CaptureOptions` is `encode::Capture` in both moq-audio and + moq-video. +- `Control::cut()` on video tells the caller when the backend cannot force a + keyframe. Today the driver logs one warning on `CutUnsupported` and keeps + the GOP cadence, so a recording or resume boundary silently never appears. +- Dropping the last `Control` ends the driver promptly, including while the + startup probe, `capture::open`, `Sink::open`, or an encode is in flight. + Today those awaits never see the handle close, so a camera or permission + prompt can outlive its owner. +- Capture publishers stamp on the clock their catalog advertises, with no + separate clock to pass. Today both `CaptureOptions` carry their own + `clock: moq_mux::Clock`, and `Default` builds a fresh one, so a caller + relying on the default publishes against a mapping the catalog never + advertised. `moq import capture` passes `catalog.clock()`, the only correct + value. + +## Plan + +Decided: + +- Rename to `encode::Capture`, which reads as the capture half next to + `encode::Options`. Update moq-cli and the docs; moq-ffi and moq-c do not + call the capture paths, so no binding mirror exists today. +- `cut()` fails loud with an error rather than logging. The encoder is opened + lazily, so the handle may not know yet; the probe already opens one, which + is one place to learn it early. Choose between `cut()` returning + `Result` (refusing once the backend is known) and the driver ending with + `CutUnsupported`, and record the choice here. +- Race every await in the driver against the controls closing, rather than + only the idle wait, so the probe and the demand-driven opens both cancel. +- Drop the `clock` field from both options; `Control::new` already takes the + catalog producer, so it reads `catalog.clock()`. Update moq-cli and any + binding that forwards a clock. The clock fixtures in both crates already + pass the catalog's clock, so they keep grading the same path. This absorbs + the former m2 capture-clock-source quest: it breaks the same `dev` options, + so one break lands instead of two. + +This is a `dev` break layered on #4184; land it on `dev` before the release +that first publishes these handles. + +Tests: a backend without forced keyframes surfaces `CutUnsupported` to the +caller; dropping the last `Control` during a slow fake open or probe returns +from `Driver::run` without finishing the open. diff --git a/quest/m1/capture-reanchor.md b/quest/m1/capture-reanchor.md new file mode 100644 index 0000000000..df3ebb1fb5 --- /dev/null +++ b/quest/m1/capture-reanchor.md @@ -0,0 +1,21 @@ +# [XS] Native capture re-anchors above its last timestamp + +## Goal + +A device clock that repeats or restarts never rewinds native video capture, +even while the pump drains a backlog faster than real time. `FrameChannel::push_native` +in `rs/moq-video/src/capture/channel.rs` re-anchors such a frame to its +arrival time ([#4125](https://github.com/moq-dev/moq/pull/4125)). During a +fast drain the previous frame's mapped timestamp can sit ahead of arrival, so +the re-anchor lands below it: source 0 and 40 ms arriving 1 ms apart publish +near 0 and 40 ms, and an immediate reset to zero publishes near 2 ms. Past a +closed group that is `TimestampRewind`, which stops capture. + +## Plan + +Remember the last mapped timestamp and floor the re-anchor strictly above it, +including the fallback when the checked arithmetic fails. The mapping keeps +advancing with the device clock from there. + +Unit regression in `channel.rs`'s mapping tests: two frames drained 1 ms apart +with a 40 ms source step, then a source reset, maps above the second frame. diff --git a/quest/m1/catalog-wall-clock.md b/quest/m1/catalog-wall-clock.md new file mode 100644 index 0000000000..1b25deeeef --- /dev/null +++ b/quest/m1/catalog-wall-clock.md @@ -0,0 +1,28 @@ +# [XS] Catalog wall clock keeps sub-millisecond precision + +## Goal + +`hang::catalog::Clock::wall_clock` returns the wall time at the precision the +catalog carries, not truncated to milliseconds. The wire field is `{ wall, +timescale }` with a `u32` timescale defaulting to microseconds, and `pts` +arrives at its own timescale, but `wall_clock` divides down to Unix +milliseconds before building the `SystemTime`, which holds nanoseconds. The +capture clock fixtures from [#4125](https://github.com/moq-dev/moq/pull/4125) +assert at millisecond precision because of it. + +## Plan + +- Rust: compute the offset from the moq epoch in nanoseconds (or as a + `Duration` from whole seconds plus the remainder at the clock's timescale) + and add it to `UNIX_EPOCH + MOQ_EPOCH_UNIX_MILLIS`. Keep the existing + overflow and JSON-safe range checks. Tighten the fixtures that currently + assert milliseconds. +- JS: `wallClockTime` in `js/hang/src/catalog/clock.ts` returns a `Date`, + which only holds milliseconds. Leave its return type alone unless a caller + needs more; note the platform limit in its doc so the two sides are not + mistaken for a mismatch. +- Callers that format the value (HLS `EXT-X-PROGRAM-DATE-TIME`, DASH + `availabilityStartTime`) choose their own output precision; check none + relied on the truncation. + +Public API and wire: none. diff --git a/quest/m1/cli-given-flags.md b/quest/m1/cli-given-flags.md new file mode 100644 index 0000000000..1bb750ba4e --- /dev/null +++ b/quest/m1/cli-given-flags.md @@ -0,0 +1,23 @@ +# [XS] Dial-only and local verbs refuse every accept-side flag + +## Goal + +`moq fetch`, `moq ls` (#4032, on `dev`), and the local verbs behind `Invocation::reject` refuse any listener +flag they would never use, as they already do for `--listen` and the cluster +flags. Today `MoqSide::given()` in `rs/moq-cli/src/args.rs` lists only some +of them, so `--listen-version`, `--listen-tls-*`, `--listen-preferred-*`, and +`--listen-quic-lb-*` are silently ignored. Codex found it on +[#4121](https://github.com/moq-dev/moq/pull/4121). + +## Plan + +- Cover every accept-side flag. Prefer a list that cannot fall behind the + server config, such as deriving it from the parsed partial or a test that + walks every `--listen-*` flag clap knows and asserts `given()` reports it, + over extending the hand-written array again. +- While there, check whether the local verbs also ignore `--connect-*` + flags; `reject` is meant to refuse the whole MoQ side. +- Test that each verb refuses a representative flag from every family, + naming it. + +Public API: none (CLI refuses input it used to ignore). Wire: none. diff --git a/quest/m1/cluster-routing.md b/quest/m1/cluster-routing.md new file mode 100644 index 0000000000..983b880eb6 --- /dev/null +++ b/quest/m1/cluster-routing.md @@ -0,0 +1,124 @@ +# [XL] Cluster routing + +## Goal + +A broadcast event reaches each relay at most once, and a relay learns only the +prefixes its own clients asked for. An announcement says a path exists at an +origin relay, at a cost; how to reach that origin comes from a shared relay +topology, so no announcement inside a cluster carries a hop list. This quest +records the design; its wire and implementation quests are planned from the +simulator's report. + +Non-goals: warm re-origination (a warm relay would be one more origin with a +cost, so leave room for it), and a permanently mixed-version cluster. + +## Plan + +### Why not path vector or Babel + +Today every relay advertises its best route to every peer not already in the +hop chain. One publish costs about R·(d-1) announces for R relays of mesh +degree d, every relay learns every broadcast (`.stats` and `.internal` +included), and a link change rewrites every route crossing it. On moq.pro's +live fleet (26 PoPs, average degree about 5) each relay receives every event +about five times. Babel ([RFC 8966](https://www.rfc-editor.org/rfc/rfc8966)) +shrinks the message and skips equal-cost reroutes, but it is still distance +vector: the same fan-out, the same global knowledge, and no loop freedom +when several sources claim a prefix (section 2.7), which pools and wildcards +make MoQ's common case. + +### Decisions + +- Existence is split from reachability. An announcement carries the path, its + origin relay, and the origin's cost, and nothing about the path to it. +- An existence event carries the origin's seqno, scoped to its incarnation. A + relay applies an event only when it is newer than the last it applied for + that path and origin, and keeps an ended path's seqno, so a start delayed on + a stale tree or a failed-over registry cannot revive it. Babel keeps + feasibility past withdrawal for the same reason + ([RFC 8966 section 3.7.3](https://www.rfc-editor.org/rfc/rfc8966#section-3.7.3)). +- The topology is configured: `--cluster-connect` or the connect API gives the + relay graph and link costs. Relays flood per-link liveness among themselves + with a per-link seqno. The seqno is scoped to the relay's incarnation, so a + restarted relay's links supersede its stale ones instead of looking older. + Gossip discovery (`cluster.mesh`) stays for zero-config self-hosting and + derives the topology from what it discovers; it need not scale. +- A relay picks the origin with the lowest shortest-path distance plus origin + cost, ties broken by rendezvous hashing (HRW) of the requested path and the + origin id, and forwards along its shortest path. Distance compares cost, + then hop count, so every hop strictly shortens it even across `?cost=0` + links. That is a shortest path to a virtual node linked to every origin, so + it is loop-free whenever relays agree on the topology. Specificity still + ranks first, per [Wildcard](/quest/m0/wildcard/README.md). +- The first relay's choice rides the SUBSCRIBE, and transit relays forward + toward that origin by topology alone, never re-selecting. Re-selection + against another existence view loops: a relay that lost a specific claim + falls back to a broader one through a relay still routing to the specific + one ([RFC 8966 section 3.5.4](https://www.rfc-editor.org/rfc/rfc8966#section-3.5.4)). + If the origin no longer serves the path, it refuses, and the first relay + selects again. +- SUBSCRIBE and FETCH carry a visited-relay list end to end. It catches loops + while liveness views disagree and names the path for stats. Narrowing it to + cluster hops is later work. The serving origin's identity rides the reply, + per Wildcard's Spread quest. +- Announcements are on demand. A relay forwards only the union of its clients' + ANNOUNCE_REQUEST prefixes, never the empty prefix. A wide prefix that many + edges' viewers request is that customer's cost. `.stats` becomes ordinary + demand. +- Registries are an optional, configured tier: moq-relay in a registry mode, + one or more per region. + - An ingest relay registers its broadcasts with its nearest registry, and an + edge sends its ANNOUNCE_REQUEST there. + - Registries form a small full mesh and flood existence among themselves, so + an event crosses an ocean once per remote registry, not once per relay. + Announce latency is about one round trip to the nearest registry, + whatever the path length. + - A relay fails over to the next-nearest registry and reconciles its view + instead of treating the lost session as ends, so a registry failure never + reports a live broadcast offline. + - With no registry reachable, a relay freezes: it keeps its view, learns + nothing new, and alerts. Falling back to flooding would cascade the + failure. +- Without registries (self-hosting), existence floods along the shortest-path + tree, one copy per relay. A relay forwards an event only when it changes its + view, so a duplicate copy, from trees built on disagreeing liveness, stops + there. +- Between clusters, announcements stay path vector with cluster ids as the + hops, like BGP between autonomous systems. A customer's on-prem cluster is + one hop, and an announcement naming the receiving cluster is dropped. +- The cluster switches versions as a whole; older lite and IETF sessions stay + at its edges. + +### Open questions + +- Sharding registries by HRW over a prefix key once one registry cannot hold + everything, and what that key is. +- A mixed-version bridge, if a fleet cannot switch at once. +- How long a relay keeps an ended path's seqno. A new origin incarnation + clears it; within one, it must outlive every delayed copy of the start. +- How an edge routes a SUBSCRIBE for a path none of its clients asked to + announce. It holds no route for it, and asking a registry first adds a round + trip before the first byte. +- What a cold ANNOUNCE_REQUEST reports as live. Answering from the local view + keeps a relay from waiting on peers but reports an empty set until the + registry's replay lands, on every new prefix rather than in a rare race. +- What remains of announce compression's hop-tail half (`Hop Base` and + `Hop Keep` in the lite draft) once only cluster boundaries carry hops. +- What replaces `--hop` first-hop failover. Today two publishers sharing a Hop + ID are one source that relays fail over between at a group boundary + (`doc/bin/cli.md` "Redundant publishers", + `doc/concept/use-case/contribution.md`). Inside a cluster no announcement + carries a hop list, so two encoders on different ingest relays become two + origins. Keep the documented behavior or change the docs in the same PR. + +## Required + +- moq.pro workers stop electing on hop chains, reading the relay's local origin instead ([moq.pro voice-local-origin](https://github.com/moq-dev/moq.pro/blob/main/quest/m0/voice-local-origin.md)) +- [Wildcard](/quest/m0/wildcard/README.md) - the specificity, pool spread, and reply identity this selection builds on +- moq.pro's routing simulator reports ([quest](https://github.com/moq-dev/moq.pro/blob/main/quest/m1/routing-simulator.md)) + +## Related + +- [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - cuts duplicate updates on today's routing +- [Redundant ingest](/quest/m2/redundant-ingest.md) - builds on the `--hop` failover this must keep or replace +- [Routing cost domains](/quest/m2/routing-cost-domains.md) - cost across the cluster boundaries this keeps path vector diff --git a/quest/m1/cmaf-opus-dops.md b/quest/m1/cmaf-opus-dops.md deleted file mode 100644 index 0456fb6f25..0000000000 --- a/quest/m1/cmaf-opus-dops.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Carry Opus pre-skip and gain through CMAF - -## Goal - -An Opus track imported from or exported to fMP4 keeps the pre-skip and output -gain its `dOps` box declares, so it decodes the same as the OpusHead it came -from. - -## Plan - -The fMP4 importer builds an Opus catalog entry from the sample entry alone and -publishes no description, so a CMAF Opus track decodes with no pre-skip or -gain. Build the OpusHead from `dOps` (input rate, channels, pre-skip, gain) -with `moq_mux::codec::opus::Config` and publish it as the description. The -exporter writes `dOps.output_gain` as 0; take it from the parsed head like the -pre-skip. Refuse a `dOps` channel mapping family other than 0 if `mp4-atom` -exposes one, rather than dropping its table. - -Regression: an fMP4 Opus fixture with nonzero pre-skip and gain round-trips -through import and export with both preserved. diff --git a/quest/m1/cmaf-opus-surround.md b/quest/m1/cmaf-opus-surround.md new file mode 100644 index 0000000000..da3ae24b66 --- /dev/null +++ b/quest/m1/cmaf-opus-surround.md @@ -0,0 +1,22 @@ +# [S] Carry surround Opus through CMAF + +## Goal + +An fMP4 Opus track whose `dOps` declares channel mapping family 1 imports with +its mapping table in the OpusHead description, and a family 1 description +exports to a `dOps` that keeps the table. Today both directions refuse it. + +## Plan + +Build the head from the `dOps` family and table with `opus::Mapping` on import, +and write the table from the parsed head on export in place of the +`UnsupportedMappingFamily` refusal in `synthesize_audio_trak`. Keep refusing +families the head cannot describe. + +Regression: a family 1 5.1 `dOps` round-trips through import and export with +its table intact. + +## Required + +- [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - `Dops` carries the mapping family and table +- [Audio codecs](/quest/m1/audio-codecs/README.md) - `opus::Mapping` and a `Config::encode` that writes any family's table diff --git a/quest/m1/cpp/README.md b/quest/m1/cpp/README.md index 4e37724aca..621d217e36 100644 --- a/quest/m1/cpp/README.md +++ b/quest/m1/cpp/README.md @@ -9,8 +9,8 @@ cancellable futures, with no `user_data` plumbing, no handle integers, and no thread of their own to babysit. The OBS plugin is the in-tree consumer that proves the shape; external SDK users are the audience. -Non-goals: moq-c stays as the plain-C ABI, keeps its own release, and keeps -following the Cross-Package Sync table like every other wrapper; no +Non-goals: the plain-C ABI, which moves to C generated from moq-ffi in +[Generated C bindings](/quest/m1/c/README.md); no second hand-written C++ surface (ergonomics are fixed in moq-ffi where every binding benefits); no wire or public Rust API change. @@ -47,16 +47,33 @@ remote we own, the latter two fetching the prebuilt tarball so consumers never need a Rust toolchain or the bindgen fork. vcpkg lands first; the Conan recipe reads the same release manifest so a release bumps both. -## Quests +Confirmed in [#4100](https://github.com/moq-dev/moq/pull/4100): + +- The fork's base is LiveKit's PR #1 (`uniffi-0.31-async`), not PR #5, + which runs a worker thread per in-flight future and has no `then()` or + async callback interfaces, both of which OBS needs. +- Fork tags keep upstream's `v+v` scheme with a + `-kixelated.N` pre-release (`v0.11.0-kixelated.1+v0.32.2`), matching the + Dart fork, so they never collide with an upstream tag. +- Cancelling abandons the future rather than delivering an error: a generic + `E` has no cancelled variant, and a `std::variant` would + burden every call site. +- Callback interfaces are refused under `error_style = "expected"` until a + consumer needs one; their bridge is built on `std::exception_ptr`. +- MSVC is covered by the post-merge nightly, not a branch dispatch: branches + never dispatch the nightly. + +## Required - [Generator](/quest/m1/cpp/generator.md) - the uniffi 0.32 C++ generator with futures and expected-style errors, pinned and generating `cpp/ffi` in CI - [Package](/quest/m1/cpp/package.md) - the `cpp/moq` wrapper, CMake package, release tarball, interop client, and docs +- [Cancel](/quest/m1/cpp/cancel.md) - a cancelled or consumed future reports `valid() == false`, like `std::future`, and a read of it aborts with a message naming the misuse +- [C++ standard](/quest/m1/cpp/cxx-standard.md) - a consumer that sets C++23 only on its own target links the package - [OBS migration](/quest/m1/cpp/obs.md) - the OBS plugin moves from moq-c handles and trampolines to the generated C++ ## Related - [C# through moq-ffi](/quest/m2/cs/README.md) - the same recipe with NordSecurity's C# generator - [Unreal prototype](/quest/m2/unreal.md) - a UE5 module consumes the package with exceptions disabled -- [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser reaches moq-ffi through a generator too; shares the Task-per-target findings - [vcpkg registry](/quest/m2/cpp-vcpkg.md) - a registry we own serves the prebuilt package to `vcpkg` manifests - [Conan remote](/quest/m2/cpp-conan.md) - a remote we own serves the same tarball to `conan install` diff --git a/quest/m1/cpp/cancel.md b/quest/m1/cpp/cancel.md new file mode 100644 index 0000000000..8faad9eacf --- /dev/null +++ b/quest/m1/cpp/cancel.md @@ -0,0 +1,26 @@ +# [XS] A cancelled future says so before it is read + +## Goal + +Callers are told to check `valid()` before reading a future that may be +cancelled, consumed, or moved from, and the tests prove it goes false in each +case. + +## Plan + +The fork half is done on the line: the `-kixelated.2` generator's +`uniffi::Future` has `valid()`, false once `get()`, `then()`, `cancel()`, or a +move took its state, like `std::future`, and a read of an invalid future aborts +with "it was consumed or cancelled". `cpp/moq/test/probe.cpp` checks `valid()` +after `cancel()`. Decided in the #4307 review; `&&`-qualifying `cancel()` and an +error-returning `get()` stay rejected. + +What remains: + +- `cpp/moq/README.md` (the Error and Cancellation sections) still only says a + misused future aborts; point callers at `valid()` instead. +- Extend the probe to check `valid()` after `then()` and after a move. +- Check the in-tree callers that can hold a cancelled future (`cpp/moq`, + `cpp/obs`) against `valid()`. + +Public API: none beyond the unreleased C++ package's `valid()`. Wire: none. diff --git a/quest/m1/cpp/cxx-standard.md b/quest/m1/cpp/cxx-standard.md new file mode 100644 index 0000000000..e051fadd9f --- /dev/null +++ b/quest/m1/cpp/cxx-standard.md @@ -0,0 +1,29 @@ +# [XS] A C++23 consumer links the package + +## Goal + +A consumer that raises the standard only on its own target +(`target_compile_features(app PRIVATE cxx_std_23)`) either links against the +`moq-cpp` package or is told clearly how to configure it. Today it gets link +errors: that requirement does not flow backward into the separate `moq-cpp` +static library, which compiles `moq.cpp` at `cxx_std_17` and so picks +`tl::expected` while the app's `` picks `std::expected`. The ABI +guard turning that into a link error is correct; the surprise is not. Codex +found it on [#4187](https://github.com/moq-dev/moq/pull/4187). + +## Plan + +- Fix the misleading comment in `cpp/moq/cmake/moq-cpp-config.cmake.in` + (and the matching one in `cpp/moq/CMakeLists.txt`): the bindings compile + with the project's standard (`CMAKE_CXX_STANDARD`) or C++17, not with + whatever the including target asks for. +- Then choose the smallest fix that makes the C++23 path work: document + `CMAKE_CXX_STANDARD` (what the probe does) in `cpp/moq/README.md` and the + C++ docs page, or add a package option that sets the standard `moq-cpp` + compiles with. Prefer documentation unless a consumer (OBS, Unreal) needs + the option. +- Test the documented configuration: the package test already builds at + several standards; add one that builds the app at C++23 the documented + way. + +Public API: none, or one CMake option. Wire: none. diff --git a/quest/m1/cpp/generator.md b/quest/m1/cpp/generator.md index 19ac2fe6c2..1a3c917d0d 100644 --- a/quest/m1/cpp/generator.md +++ b/quest/m1/cpp/generator.md @@ -44,5 +44,4 @@ stop here and write down why. ## Related -- [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser generator spike; same Task and `#[cfg]`-inside-export gotchas apply - [C# generator](/quest/m2/cs/generator.md) - the same 0.32 port against NordSecurity's C# generator diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md new file mode 100644 index 0000000000..95d320ac35 --- /dev/null +++ b/quest/m1/data-capture-bindings.md @@ -0,0 +1,55 @@ +# [M] Bindings stamp data frames with a capture time + +## Goal + +A moq-ffi publisher, and every wrapper over it (Python, Swift, Kotlin, Go, +Dart), can pass a capture time with a JSON or binary snapshot `update` or +stream `append`, so its data tracks advertise `delay` and `jitter` like a Rust +publisher's. Leaving it out keeps today's behaviour. The Go and Python +wrappers gain the binary snapshot and stream producers they lack. `moq-json`'s `window` +producer takes a capture time too. Settled scope: moq-ffi and its wrappers, +not moq-c. + +## Plan + +- #4270 gives the Rust producers `moq_net::Timed`, built with + `Timed::from(value).at(t)`. The `moq-mux` data producers take + `Timed<_, Instant>`, map it onto the broadcast clock, and refuse one ahead of + now (`Error::InvalidCapture`). The moq-ffi producers in + `rs/moq-ffi/src/{json,binary}.rs` pass bare values. +- Settled: the capture time is a media timestamp on the broadcast's + timeline. moq-ffi exposes the broadcast clock's `now()` as a timestamp; + callers stamp payloads with values taken from it, and moq refuses one ahead + of now. That keeps a device or process clock out, as the Rust `Instant` + mapping does. The `moq-mux` producers take an `Instant` today, so either + map the timestamp back through the clock inside moq-ffi or give the clock a + typed timestamp moq-mux accepts; keep a raw `Timestamp` from compiling + there. Make it optional on the existing methods, never a `_with_capture` + twin. +- `moq-json` window: `window::Producer::push` stamps `Timestamp::now()` + (`rs/moq-json/src/window/producer.rs`). Accept `Timed` as the snapshot and + stream producers do. Nothing in `moq-mux` publishes window mode, so there is + no estimator to feed. +- Wrappers follow per the cross-package sync table, each with a test that a + past capture time is accepted and a future one refused. +- Go (`go/wrapper/moq`) and Python (`py/moq-rs`) wrap only the JSON + producers; [#4137](https://github.com/moq-dev/moq/pull/4137) added + `publish_binary_snapshot` / `publish_binary_stream` to moq-ffi without + them. Add hand-written binary wrappers there, capture time included, so + the capture tests cover binary too. The maintainer asked for this. Update + `doc/lib/{py,swift,kt,go,dart}`. + +Public API: breaking, so it lands on `dev`. A new parameter on the generated +`update` and `append` breaks every published binding caller (Go, for one, has +no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock +`now()` and the Go and Python binary producers are additive; +`window::Producer::push` accepts `Timed`, source-compatible. +Wire: none. + +## Required + +- [JSON and flate namespaces](/quest/m1/ffi-shape/json.md) - moves the data producers this changes, so the two breaks land in order rather than colliding + +## Related + +- [Generated C bindings](/quest/m1/c/README.md) - replaces libmoq, so C inherits this from moq-ffi diff --git a/quest/m1/data-jitter.md b/quest/m1/data-jitter.md deleted file mode 100644 index 3ccf0386a4..0000000000 --- a/quest/m1/data-jitter.md +++ /dev/null @@ -1,47 +0,0 @@ -# [S] moq-mux: detect delay and jitter on JSON and binary tracks - -## Goal - -A JSON or binary track whose payloads carry a capture time on the publisher's -own clock (a UDP datagram's arrival, a sensor read) advertises a detected -`delay` and `jitter`: how late the publisher hands payloads to the transport -relative to that time, the same measurement `moq_mux::catalog::Estimator` -makes for encoders. A -telemetry source slower than the video it accompanies shows up as `delay`. -Tracks written without a capture time advertise neither rather than a -meaningless zero. - -## Plan - -- The `moq-binary` and `moq-json` producers stamp each frame with - `Timestamp::now()` at write, so flush lateness is always zero today. Let a - payload carry its capture timestamp, written as the frame timestamp, without a - `_with_x` twin of `update`/`append` (for example, accept a type that converts - from a bare payload). -- The capture time must be on the broadcast's `moq_mux::Clock`, the timeline - media PTS use. `Timestamp::now()` has its own jittered per-process epoch, so - a data frame stamped with it cannot be compared with media; map the capture - instant through the broadcast clock, in a type that cannot be mistaken for - an unmapped `Timestamp`. A device-native clock (a flight controller's boot - time) is an unrelated epoch that would report nonsense and, through the - broadcast-wide baseline, distort every other track; it stays in the payload. - Reject a timestamp ahead of the clock's `now` rather than clamp it. -- Add optional `delay` to `JsonConfig` and `BinaryConfig` in `rs/hang`, - `js/hang`, and the draft, beside `jitter`. -- Feed the catalog's flush clock from the `moq-mux` data producers when a - capture time is present and a frame was actually emitted. A `moq-json` - snapshot `update` with an unchanged value succeeds without writing one, so - the lower producer reports whether it emitted, and a repeated value must not - move the baseline (cover it in tests). Publish the result through the embedded config's - `Estimate`, as the data producers already do for bitrate. -- Have the lower producers also report each emitted frame's encoded size, and - measure bitrate from that instead of the pre-compression payload or - serialized value: today an unchanged snapshot `update` still counts, and - DEFLATE can slightly expand an incompressible payload. -- Mirror the capture timestamp in the published `js/binary` and `js/json` - producers, so browser publishers can produce the same timed tracks. - -Public API: additive on `hang`, `moq-binary`, `moq-json`, `moq-mux`, -`@moq/hang`, `@moq/binary`, and `@moq/json`. Wire: one optional field on data -entries. - diff --git a/quest/m1/doc-samples-go-dart.md b/quest/m1/doc-samples-go-dart.md new file mode 100644 index 0000000000..6680e0a034 --- /dev/null +++ b/quest/m1/doc-samples-go-dart.md @@ -0,0 +1,35 @@ +# [S] Go and Dart doc samples compile against their wrappers + +## Goal + +The Go and Dart pages in `doc/lib/go/` and `doc/lib/dart/` are checked the +way [#4049](https://github.com/moq-dev/moq/pull/4049) checks Python, Kotlin, +Swift, and C: every fenced sample is extracted by `doc/lib/samples.sh` and +type-checked against the wrapper it documents (`go vet` for `go/wrapper`, +`dart analyze` for `dart/moq`), so a renamed wrapper method breaks the +language's check instead of silently breaking the docs. Today `samples.sh` +knows neither language. + +## Plan + +- Add `go` and `dart` to `samples.sh` with the same shape: one function per + sample, imports hoisted, inputs a sample leaves undefined (`client`, + `opusInit`, `pts`) supplied by a prelude the caller compiles alongside. +- Go is stricter than the others: a file needs a `package` clause, imports + come as single lines or a parenthesized block, and unused locals and + imports are compile errors. Handle what the samples actually use; prefer + adjusting a sample so it reads naturally and still compiles over growing + the extractor. +- Wire each into its language's check script, `sh/go/check.sh` and + `sh/dart/check.sh` on the tooling line, the way `sh/kt/check.sh` and + `sh/py/samples.sh` call `samples.sh`. Add `doc/lib/go/`, `doc/lib/dart/`, + and `doc/lib/samples.sh` to the `go` and `dart` patterns of the impact + map in `sh/dispatch.sh`, as the `py`, `kt`, and `swift` ones already have. +- Prove it by renaming one wrapper method locally and watching each check + fail on the doc sample. + +Public API: none. Wire: none. + +## Required + +- [Tooling](/quest/m1/tooling/README.md) - the `sh//` check scripts and the impact map this extends diff --git a/quest/m1/docker-slim.md b/quest/m1/docker-slim.md new file mode 100644 index 0000000000..1b8067ef79 --- /dev/null +++ b/quest/m1/docker-slim.md @@ -0,0 +1,27 @@ +# [S] Slim Docker images + +## Goal + +Each published image contains only its package's nix closure. Today the +final stage of `Dockerfile` is `nixos/nix:latest`, which puts about 170 MiB +of nix runtime into every image. `moqdev/moq-relay` is about 186 MiB, and +`moqdev/moq-token-cli` is 172 MiB for a 1.6 MiB binary. + +## Plan + +Decided in planning: use a `scratch` final stage holding the closure and the +binary, with no shell. Losing `docker exec ... sh` debugging is accepted. + +Guidance: + +- `entry.sh` needs `/bin/sh`, and exec-form `ENTRYPOINT` doesn't expand build + args. In the builder stage, create a fixed-name symlink to the package's + binary and point `ENTRYPOINT` at it. +- Check what the runtime needs outside the closure: CA roots for outbound + TLS (cluster peers, auth fetches), `/tmp`, and a non-root user if the + current image has one. The package's closure should carry them; verify + outbound TLS works rather than assume it. +- Decide what happens to the no-package `sh` default, which only makes + sense with a shell. +- Report before and after image sizes for each published image, and update + any docs that `docker exec` into a shell. diff --git a/quest/m1/drain-before-close.md b/quest/m1/drain-before-close.md new file mode 100644 index 0000000000..f24ff82b57 --- /dev/null +++ b/quest/m1/drain-before-close.md @@ -0,0 +1,40 @@ +# [M] Deliver queued stream data before a client closes + +## Goal + +A process that finishes its tracks and then closes its `moq_tokio::Client` +delivers what it already queued, including each stream's FIN, before the +connection closes, bounded by a deadline. `moq import` at stdin EOF is the +consumer: a subscriber over a real relay sees the catalog finish instead of +`Error::Dropped`. + +## Plan + +- [#4303](https://github.com/moq-dev/moq/pull/4303) made `moq import` finish + the catalog at EOF, but only in-process: over a real session the process + exits and the close discards the queued finish. + [#4287](https://github.com/moq-dev/moq/pull/4287) added `Client::close`, + which sends the CONNECTION_CLOSE but does not wait for stream data. +- A QUIC close discards unacknowledged stream data, so the session has to + wait until its open send streams are written, finished, and acknowledged, + not only handed to the transport. The pending group and control writes live + in moq-net's session tasks; the transport wait lives in moq-tokio. +- Bound the wait so a stalled peer cannot hang an exiting process. Share the + deadline and the graceful path with + [Session close](/quest/m1/session-close.md), which waits for announce + acknowledgements the same way; `abort` and drop stay immediate. +- Cover every backend `Client::close` covers, and say which do not drain + (WebSocket and iroh end on drop today). +- Regression tests: a moq-tokio test, shaped like + `noq_client_close_reaches_server`, where the client finishes a track and + closes on a runtime dropped right after, and the server's subscriber reads + the finish rather than a drop. Then a CLI test piping a file through + `moq import` to a relay. + +Public API: likely additive (`Client::close` gains the drain, or a graceful +close sits beside `abort`). Wire: none. + +## Related + +- [Session close](/quest/m1/session-close.md) - the graceful end that withdraws announces +- [Graceful relay drains](/quest/m1/drain/README.md) - the server-side drain over GOAWAY diff --git a/quest/m1/drain/README.md b/quest/m1/drain/README.md index 96d9195bb8..786b65f106 100644 --- a/quest/m1/drain/README.md +++ b/quest/m1/drain/README.md @@ -29,6 +29,11 @@ them (a planned-drain health state, the SIGTERM sequencing and stop timeouts, per-PoP serial deploys, a two-node PoP floor, and the gateway drain contract) is moq.pro's (downstream) fleet drain work, which consumes these quests. +Drain stays at the MoQ layer. WebTransport's `WT_DRAIN_SESSION` capsule and +the browser `draining` promise are advisory and carry no redirect URI or +timeout, and qmux and WebSocket have no equivalent, so neither the relay nor +the clients send or act on them (decided 2026-09-26). + **relay-drain-api.** A drain hook that GOAWAYs every established session and immediately GOAWAYs any new arrival, so an embedding process can enter drain on SIGTERM after the DNS window and still bound the total stop time. Expose @@ -41,7 +46,7 @@ scale-down prerequisite, not merely a deploy improvement. RTMP/SRT/WHIP/WHEP cannot receive MoQ GOAWAY, so their contract remains DNS withdrawal followed by the stop deadline and encoder reconnect. -## Quests +## Required - [Relay drain api](/quest/m1/drain/relay-drain-api.md) - a drain hook that GOAWAYs every session, including new arrivals, triggered by the embedding @@ -49,7 +54,8 @@ by the stop deadline and encoder reconnect. - [Client goaway](/quest/m1/drain/client-goaway.md) - the JavaScript client migrates on GOAWAY with a handover and the guarded redirect the Rust client already has, and the Rust drain path gets its regression test +- [JS GOAWAY requests](/quest/m1/drain/js-goaway-requests.md) - after GOAWAY the JS client opens no new request on the old session, like Rust ## Related -- [pop-skipping](/quest/m1/pop-skipping/README.md) - its same-PoP link price and full eligible pairing become important when a deployment adds a second relay per PoP +- [Cluster routing](/quest/m1/cluster-routing.md) - the configured topology and link costs a second relay per PoP joins diff --git a/quest/m1/drain/js-goaway-requests.md b/quest/m1/drain/js-goaway-requests.md new file mode 100644 index 0000000000..bf8de798b2 --- /dev/null +++ b/quest/m1/drain/js-goaway-requests.md @@ -0,0 +1,35 @@ +# [S] JS refuses new requests after GOAWAY + +## Goal + +Once a `@moq/net` session has received GOAWAY, it opens no new subscribe, +fetch, or announce-interest stream on that session, on lite and IETF, as +moq-net already refuses them with `Error::GoingAway`. Existing subscriptions +keep flowing until the handover ends, and a refused request is served by the +replacement session rather than failing the caller. + +## Plan + +The drain line's JS migration (https://github.com/moq-dev/moq/pull/4143) +keeps the old session serving after GOAWAY but left its subscribers unaware +of it, so during the handover an origin request can still open a stream on a +session the peer asked us to leave. The lite draft says the recipient must +not open new streams after GOAWAY, and a compliant draining relay rejects +them. Rust checks before every new open in both wires (`check_going_away` in +the lite subscriber, the `going_away` checks in the IETF one). + +Guidance: + +- The drain signal already reaches the connection wire view + (`wireOf(session).goaway`). Hand it to both subscribers and check it at + every open site, including the draft-14 to -16 adapter route. +- The refusal matters as much as the check: the origin should see it as this + route declining, so the request moves to the replacement's route (the + origin already keeps the outranked route until the new one answers). Match + what the Rust origin does with `GoingAway` rather than inventing a policy. +- Test on lite and IETF with the existing mock transports: after GOAWAY a new + request opens no stream on the old session and resolves on the new one, + while an existing subscription keeps receiving groups. + +No public API change is expected; the error is internal unless a caller can +observe it today. diff --git a/quest/m1/dropped-sources.md b/quest/m1/dropped-sources.md index 9fc495174f..4c20212884 100644 --- a/quest/m1/dropped-sources.md +++ b/quest/m1/dropped-sources.md @@ -2,26 +2,33 @@ ## Goal -A track or broadcast that ends because its source ended reports the source's -own error to every consumer, locally and across a relay. `Dropped` means only -that a handle was dropped without an end, which a correct producer never does. -#4179 fixed one path (a revoked upstream subscription now reads -`Unauthorized`); the rest still surface `Dropped`. +A track that ends because its source ended reports the source's own error to +every consumer, locally and across a relay. `Dropped` means only that a handle +was dropped without an end, which a correct producer never does. A broadcast +end carries no cause since #4031, so only track errors are in scope. ## Plan -- Known sources, from #4179: a source closing, a route leaving the origin's - table, and a withdrawn source broadcast. Find each place a consumer can - observe `Dropped` and make the ending side carry its real error (an explicit - `abort` or a preserved cause), at the source rather than by remapping at the - consumer. -- moq-transport: a `PUBLISH_DONE` carrying Unauthorized arrives as - `Error::Remote(1)`. Map it to the same error lite reports. -- Regression tests per path, each failing on `Dropped` today, in-process and - over a mock session. +- Rust already maps IETF `PUBLISH_DONE` Unauthorized to `Error::Unauthorized`, + and a closed source's standing route no longer re-requests it. +- #4179 fixes revoked upstream subscriptions and #4120 preserves session death + and resumed-track errors. After #4179 reaches `main`, verify the remaining + track paths (source close, route removal, broadcast withdrawal) locally and + over a mock session, and map JS `PUBLISH_DONE` Unauthorized to #4179's shared + error. Preserve causes at the source rather than remapping `Dropped` at + consumers. +- The same goes for revocation. Leftovers from #4179's review: a bridged + revocation reaches Rust IETF subscribers as `PUBLISH_DONE` InternalError, a + JS relay reports a route revocation as INTERNAL_ERROR, and the bindings + neither document nor test `is_auth` for a stream-scoped Unauthorized. Each + should surface Unauthorized. Public API: none expected; error values consumers observe change. Wire: none. +## Required + +- [Auth](/quest/m1/auth/README.md) - its Unauthorized quest (#4179, done on the line) supplies shared Unauthorized errors and revoked-stream handling + ## Related -- [#4179](https://github.com/moq-dev/moq/pull/4179) - fixed the revoked-upstream path +- [#4179](https://github.com/moq-dev/moq/pull/4179) - owns the revoked-upstream path diff --git a/quest/m1/e2ee/README.md b/quest/m1/e2ee/README.md index 824379b5c5..07e41ce3c9 100644 --- a/quest/m1/e2ee/README.md +++ b/quest/m1/e2ee/README.md @@ -42,7 +42,7 @@ The Rust and TypeScript cores expose the same surface, and nothing else: - A platform that forwards and meters protected bytes must never preview, record, archive, transmux, transcode, transcribe, compose, or inspect them, rejecting those paths before opening a processing session or writing product state. Applications needing those operations terminate E2EE outside the platform. A platform classifies protected broadcasts by its own credential or product state, never by name; the moq.pro (downstream) exclusion classifier and dashboard work stay downstream. - The first proof covers browser TypeScript and native Rust publication and playback in both directions, with grouped audio and video over both moq-lite and MoQ Transport. Shared vectors cover groups and moq-lite datagrams; MoQ Transport has no datagram delivery. -## Quests +## Required - [Receive failure](/quest/m1/e2ee/receiver-failure.md) - a bad grouped frame wakes and terminates every pending read - [TypeScript E2EE core](/quest/m1/e2ee/typescript.md) - the `@moq/e2ee` package diff --git a/quest/m1/egress-rendition-pick.md b/quest/m1/egress-rendition-pick.md deleted file mode 100644 index 828f1d66ee..0000000000 --- a/quest/m1/egress-rendition-pick.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Single-rendition egress picks the best rendition - -## Goal - -An egress that can carry one video rendition serves the highest-quality one -the client can decode, not the first by track name: WHEP (`moq-rtc`), -non-multitrack RTMP play and FLV export. A multi-rendition broadcast serves -the same picture regardless of how its tracks are named. - -## Plan - -`moq-rtc`'s `pick_track` (`rs/moq-rtc/src/egress.rs`), FLV's `bind_video` -(`rs/moq-mux/src/container/flv/export.rs`), and RTMP's -`check_play_capabilities` (`rs/moq-rtmp/src/server.rs`) each take the first -catalog entry, which is BTreeMap name order. Share one ranking with the -player's fallback and `catalog::choose_source`: largest resolution, then -highest bitrate, among renditions the egress supports. Multitrack FLV/RTMP -and TS/SRT keep carrying every rendition. Test with a catalog whose -lower-quality rendition sorts first. - -## Related - -- [WHEP ABR](/quest/m2/whep-abr.md) - switch the served rendition per peer instead of fixing one -- [Transcode](/doc/bin/cli.md) - the source is the tallest rendition this host can decode diff --git a/quest/m1/epoch.md b/quest/m1/epoch.md index d829397624..1ebdeaacdd 100644 --- a/quest/m1/epoch.md +++ b/quest/m1/epoch.md @@ -21,7 +21,7 @@ line builds on, and it replaces the e2ee-local `moq_e2ee::Epoch`. like `@alice` is valid today and stays valid: strict parsing already keeps it from reading as an epoch. Rejecting it instead would break the path contract and land on `dev`. Check how the split interacts with - [path patterns](/quest/m1/path-patterns.md) and + path patterns (done on the [auth line](/quest/m1/auth/README.md)) and hidden broadcasts (a leading `.`, see `doc/concept/moq-lite.md`). - `moq-e2ee` uses the shared type. Update [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md) so the path is diff --git a/quest/m1/error-display.md b/quest/m1/error-display.md new file mode 100644 index 0000000000..894f3581d0 --- /dev/null +++ b/quest/m1/error-display.md @@ -0,0 +1,34 @@ +# [M] Every binding prints MoqError's message + +## Goal + +Python's `str(err)`, Go's `err.Error()`, and Dart's `toString()` on a +`MoqError` return the message from moq-ffi's exported `Display`, as Kotlin's +`toString()` and Swift's `description` already do. No hand-written +per-variant strings and no extra message function. + +## Plan + +- #4292 adds `#[uniffi::export(Display)]` on `MoqError` in + `rs/moq-ffi/src/error.rs`, on the C++ line. If it has not reached `main` + when this starts, add the same attribute here; it is additive. +- Go and Dart are fixed in their generators; Python in its wrapper: + - Go: the `kixelated/uniffi-bindgen-go` fork. The generated `Error()` prints + `MoqError: `. Render the exported `Display` for errors, tag, and + bump every pin site the `flake.nix` comment lists. + - Dart: the `kixelated/uniffi-dart` fork. The regenerated bindings already + carry the unused extern; wire it to `toString()`, tag, bump `flake.nix`, + and regenerate `dart/moq_ffi`. + - Python: upstream `mozilla/uniffi-rs` (0.32.2 here) renders no uniffi + traits on errors (`ErrorTemplate.py`). Settled: no upstream PR. The + hand-written `py/moq-rs` package defines `__str__` on `MoqError` by + calling the exported `Display`, so the message still comes from Rust. +- A test per binding that a known error prints Rust's text (`Closed` prints + `closed`), and `doc/lib/{py,go,dart}` updated where they show error output. + +Public API: the string form of `MoqError` changes in Python, Go, and Dart. +Wire: none. + +## Related + +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - where #4292 adds the export and C++ `to_string()` diff --git a/quest/m1/export-linger.md b/quest/m1/export-linger.md index d8e40d1f0f..fe0f3a0808 100644 --- a/quest/m1/export-linger.md +++ b/quest/m1/export-linger.md @@ -32,7 +32,3 @@ once and exits 1 with `json: dropped`, even while `moq_tokio` is mid-reconnect - Tests: a relay-backed CLI test that restarts the publisher within the linger and sees output resume, one that lets it expire and checks exit 1, and a clean FIN that exits 0. - -## Closes - -- [#3926](https://github.com/moq-dev/moq/issues/3926) - close this issue when the quest finishes diff --git a/quest/m1/ffi-shape/README.md b/quest/m1/ffi-shape/README.md index 4fde8fa024..476b57640c 100644 --- a/quest/m1/ffi-shape/README.md +++ b/quest/m1/ffi-shape/README.md @@ -3,7 +3,7 @@ ## Goal A binding consumer finds each layer where Rust keeps it: moq-net at the root, -and `media`, `json`, `audio`, and `video` as their own namespaces, each type +and `media`, `json`, `flate`, `audio`, and `video` as their own namespaces, each type constructed from the lower-layer handle it wraps. `BroadcastProducer` and `BroadcastConsumer` stop carrying every layer's verbs, and every moq-ffi type and verb maps to a Rust one. A docs page shows the layers in each language. @@ -18,7 +18,10 @@ Settled shape: - Groups by role, not crate: the root is moq-net (client, server, session, origin, broadcast, track, group); `media` merges hang and moq-mux, since a binding never sees that split (catalog, import producers, container - consumers); `json`, `audio`, and `video` own their producers and consumers. + consumers); `json`, `flate`, `audio`, and `video` own their producers and + consumers. `flate` holds the opaque snapshot and stream tracks moq-ffi + publishes as `publish_binary_*` today (#4137), named after the crate they + fold into in [moq-binary folds into moq-flate](/quest/m1/flate-binary.md). - A layer's type is constructed from the handles its Rust constructor takes, not reached through an accessor on the broadcast: JSON wraps a track (`moq_json::snapshot::Producer::new(track, config)`), so it also works on a @@ -32,30 +35,28 @@ Settled shape: namespaces. - `demand()` is the one way to watch subscribers; producers drop their `name`/`is_used`/`used`/`unused` duplicates. -- moq-c renames its C symbols to the same groups (`moq_json_*`, - `moq_media_*`), with `cpp/obs` adapting. +- moq-ffi only. The hand-written moq-c and `cpp/obs` are out of scope: the + [generated C](/quest/m1/c/README.md) and [C++](/quest/m1/cpp/README.md) + bindings inherit this shape from moq-ffi, so reshaping the hand-written C + ABI would break C users twice. -Each child reshapes one group end to end: moq-ffi, all five wrappers, moq-c, -and the `doc/lib` samples, per the cross-package table. This README owns the +Each child reshapes one group end to end: moq-ffi, all five wrappers, and the +`doc/lib` samples, per the cross-package table. This README owns the work no child does: -- A layers guide under `doc/lib` mapping net, media, json, audio, and video to +- A layers guide under `doc/lib` mapping net, media, json, flate, audio, and video to each language's module, linked from every binding page. - The bindings section of the following release's upgrade page: old call to new call per language. - `just test interop --all` green on the finished line. -## Quests +## Required -- [JSON](/quest/m1/ffi-shape/json.md) - the pilot: json becomes its own namespace wrapping a track in every binding and sets the per-language pattern +- [JSON](/quest/m1/ffi-shape/json.md) - the pilot: json and flate become their own namespaces wrapping a track in every binding and set the per-language pattern - [Net](/quest/m1/ffi-shape/net.md) - client and server take config records, snapshots are records, and the verbs match moq-net - [Media](/quest/m1/ffi-shape/media.md) - catalog, import, and container consume move under `media` - [Codecs](/quest/m1/ffi-shape/codec.md) - audio and video encoders and decoders move under their own namespaces with one constructor shape -## Required - -- [Release](/quest/m0/release.md) - the restructure follows the release rather than riding it - ## Related - [Track demand](/quest/m1/track-demand.md) - the same `demand()` cleanup in Rust and JS diff --git a/quest/m1/ffi-shape/codec.md b/quest/m1/ffi-shape/codec.md index 5c23fb6052..a5668cfc19 100644 --- a/quest/m1/ffi-shape/codec.md +++ b/quest/m1/ffi-shape/codec.md @@ -17,9 +17,16 @@ key apart from its rendition; pick one convention for both. Go's through `demand()` only. Both groups stay behind their cargo features and off wasm. -moq-c's codec symbols follow. - -Public API: breaking in every binding and moq-c. Wire: none. +The video encoder's output mirrors moq-video's `encode::Gop`: +`MoqVideoEncoderOutput.gop: Option` becomes a `MoqVideoGop` enum with a +`Keyframe { interval }` variant, defaulting to keyframes at two seconds, and +documented as non-exhaustive like the core. The wrappers expose it as an enum +their callers construct, not one they are asked to match, so +[intra-refresh bindings](/quest/m2/intra-refresh/bindings.md) adds the refresh +variant additively instead of breaking `gop` a second time. Go gets no uniffi +default, so its zero value must read as keyframe mode. + +Public API: breaking in every binding. Wire: none. ## Required diff --git a/quest/m1/ffi-shape/json.md b/quest/m1/ffi-shape/json.md index d417b786ad..3cbf8969d6 100644 --- a/quest/m1/ffi-shape/json.md +++ b/quest/m1/ffi-shape/json.md @@ -1,11 +1,12 @@ -# [M] JSON gets its own namespace in every binding +# [M] JSON and flate get their own namespaces in every binding ## Goal -JSON tracks live under `json` in moq-ffi and every wrapper, constructed from a -track producer or consumer as in `moq-json`, and `BroadcastProducer`/`BroadcastConsumer` lose -`publish_json_*`/`subscribe_json_*`. The per-language namespace pattern this -sets is what the other children copy. +JSON tracks live under `json` and opaque tracks under `flate` in moq-ffi and +every wrapper, constructed from a track producer or consumer as in `moq-json` +and `moq-flate`, and `BroadcastProducer`/`BroadcastConsumer` lose +`publish_json_*`/`subscribe_json_*` and `publish_binary_*`. The per-language +namespace pattern this sets is what the other children copy. ## Plan @@ -25,10 +26,10 @@ keep doing so, and the rest may follow. Watch for Go import cycles: a subpackage takes the root's broadcast handle, so the root must not import it back. -moq-c's JSON symbols move to `moq_json_*`. +`flate` is the same shape over opaque bytes: moq-ffi's `binary.rs` +(`publish_binary_snapshot`, `publish_binary_stream`, #4137) moves under it, +mirroring `moq_flate::{snapshot, stream}` once +[moq-binary folds into moq-flate](/quest/m1/flate-binary.md). If that fold has +not landed, name the namespace `flate` anyway rather than `binary`. -Public API: breaking in every binding and moq-c. Wire: none. - -## Required - -- [Release](/quest/m0/release.md) - the restructure follows the release rather than riding it +Public API: breaking in every binding. Wire: none. diff --git a/quest/m1/ffi-shape/media.md b/quest/m1/ffi-shape/media.md index d00840050b..e2a0a53714 100644 --- a/quest/m1/ffi-shape/media.md +++ b/quest/m1/ffi-shape/media.md @@ -24,9 +24,7 @@ once the shape is in front of you, and prefer one path. Go's `FetchMediaGroup` takes an options struct. Media producers watch subscribers through `demand()` only. -moq-c's media symbols move to `moq_media_*`, and `cpp/obs` adapts. - -Public API: breaking in every binding and moq-c. Wire: none. +Public API: breaking in every binding. Wire: none. ## Required diff --git a/quest/m1/ffi-shape/net.md b/quest/m1/ffi-shape/net.md index 3b43630b8a..114b37b8bd 100644 --- a/quest/m1/ffi-shape/net.md +++ b/quest/m1/ffi-shape/net.md @@ -40,9 +40,7 @@ are renamed. this line removes, but dropping it costs every quick-start a hop (raised in #3959). -moq-c's affected symbols follow. - -Public API: breaking in every binding and moq-c. Wire: none. +Public API: breaking in every binding. Wire: none. ## Required diff --git a/quest/m1/ffi-size-profile.md b/quest/m1/ffi-size-profile.md new file mode 100644 index 0000000000..70a00e7363 --- /dev/null +++ b/quest/m1/ffi-size-profile.md @@ -0,0 +1,30 @@ +# [M] Bindings size profile + +## Goal + +Decide, with a benchmark, whether the moq-ffi builds for Swift, Android, +Dart, Go, and Python ship at `opt-level = "s"`. On aarch64-apple-darwin it +halved the stripped dylib from 20.0 MiB to 10.1 MiB (fat LTO, 1 CGU) and +shrank `__text` from 18.2 MB to 8.5 MB. The throughput cost is unmeasured. + +## Plan + +Decided in planning: adopt it only if the benchmark shows the cost is +negligible. The relay and CLI stay at opt-level 3 regardless. + +Guidance: + +- The `cc` crate builds the C dependencies at the profile's opt-level too: + aws-lc, openh264, and libopus. Hold the codec and crypto crates at + opt-level 3 with per-package overrides, and compare against building + everything at "s". +- Measure what the bindings actually do: publish and subscribe throughput + through moq-ffi, plus audio and video encode and decode. Wire the benchmark + into CI, at least nightly. +- If it's adopted, use a named profile (for example `[profile.ffi]`, + inheriting release) that every binding build path selects, so the relay + and self-builders keep opt-level 3. + +## Required + +- [Release profile](/quest/m1/release-profile.md) - the baseline this compares against diff --git a/quest/m1/flate-binary.md b/quest/m1/flate-binary.md new file mode 100644 index 0000000000..6cab778cb7 --- /dev/null +++ b/quest/m1/flate-binary.md @@ -0,0 +1,50 @@ +# [M] moq-binary folds into moq-flate + +## Goal + +Opaque binary tracks live in `moq-flate` and `@moq/flate`: the `snapshot` and +`stream` modes `moq-binary` and `@moq/binary` provide today move there beside +the group-scoped codec, and `moq-binary` and `@moq/binary` are deleted. One +package owns compressed and opaque tracks, so there is no second "compressed +track" wrapper to build. + +## Plan + +Decided in the 2026-09-28 quest audit: `moq-binary` already composes +`moq-flate` into per-group windows (each group one sync-flushed DEFLATE +stream), which is what the m2 flate line planned to add as a new track wrapper. +Folding the two removes the duplicate instead of building it. + +- Rust: move `rs/moq-binary/src/{snapshot,stream}` and `Compression` into + `moq-flate` as `moq_flate::{snapshot, stream}`, keeping the codec + (`Encoder`/`Decoder`) at the root. `moq-flate` gains the `moq-net` + dependency. Delete `rs/moq-binary` and its workspace member, and repoint + `moq-mux` (`src/binary.rs`, `src/error.rs`) and `rs/moq-c` if it still + exists. +- JS: move `js/binary/src/{snapshot,stream,compression.ts}` into `@moq/flate` + as `Snapshot` and `Stream`, adding the `@moq/net` and `@moq/signals` + dependencies, and delete `js/binary`. +- Wire and catalog: unchanged. The hang catalog's `binary` section and + `moq_mux::binary` keep their names; they describe the track's content, and + the catalog section is wire. +- moq-ffi: rename `binary.rs` and its `publish_binary_*`, `MoqBinaryConfig`, + and producer types after `flate`, so every binding names the crate it wraps. + If [FFI shape](/quest/m1/ffi-shape/README.md) has already given them a + `flate` namespace, follow it instead. +- Open: whether `Compression::None` survives the move. An uncompressed opaque + track still needs a home, so the recommendation is to keep it and document + that the crate name is not a promise every track is deflated. +- Docs: fold `doc/lib/rs/moq-binary.md` into a `moq-flate` page and + `doc/lib/js/binary.md` into a `@moq/flate` page, fix `doc/.vitepress/config.ts`, + `doc/lib/{rs,js}/index.md`, `doc/concept/hang.md`, the android workflow path + filter, and add an upgrade note in `doc/setup/upgrade.md`. Grep for + `moq-binary`, `moq_binary`, and `@moq/binary`. + +Public API: breaking. `moq-binary` and `@moq/binary` are published and +deleted, and moq-ffi's binary names change, so this lands on `dev`. +`moq-flate` and `@moq/flate` grow additively. Wire: none. + +## Related + +- [Compressed tracks](/quest/m2/flate/README.md) - the hand-written wrappers expose these tracks +- [FFI shape](/quest/m1/ffi-shape/README.md) - gives the flate tracks their binding namespace diff --git a/quest/m1/flv-catalog-stream.md b/quest/m1/flv-catalog-stream.md new file mode 100644 index 0000000000..72d52b79d0 --- /dev/null +++ b/quest/m1/flv-catalog-stream.md @@ -0,0 +1,13 @@ +# [S] FLV export takes a catalog stream + +## Goal + +On `dev`, `flv::Export` is built from a catalog stream like `fmp4::Export`, so +callers narrow renditions with `catalog::Stream::select` and the FLV-only +`with_select` builder is gone. + +## Plan + +This is a published API break, so it targets `dev`. Consider whether the TS +and Matroska exports should take the same shape in the same pass. RTMP play +passes its client-capability selection through the stream instead. diff --git a/quest/m1/flv-rebind.md b/quest/m1/flv-rebind.md new file mode 100644 index 0000000000..4aacafda74 --- /dev/null +++ b/quest/m1/flv-rebind.md @@ -0,0 +1,14 @@ +# [XS] FLV rebind before header + +## Goal + +A single-track FLV export whose first catalog snapshot lacks the best +rendition switches to it if it appears before the stream header goes out, so +the pick doesn't depend on catalog timing. + +## Plan + +`flv::Export` binds from the first snapshot and ignores later ones in +single-track mode. Before the header, a better-ranked rendition could replace +the bound one; after it, FLV can't introduce a new config, so the current track +stays. Mock time in the test. diff --git a/quest/m1/frame-slot-charge.md b/quest/m1/frame-slot-charge.md new file mode 100644 index 0000000000..d7c199e5aa --- /dev/null +++ b/quest/m1/frame-slot-charge.md @@ -0,0 +1,53 @@ +# [S] Frame slots are cached for free + +## Goal + +A group's frame slots are charged against the cache pool, so a track producing +many small frames per group is billed for what it holds. The fixed per-group +overhead already covers the first few slots; this is the growth past them, and +the capacity a released group keeps. + +## Plan + +Re-planned from the closed #3546 against the current accounting in +`rs/moq-net/src/model/group.rs`. + +`GroupState::cache` and its `cache::Charge` count frame payload bytes only. The +frames live in a `VecDeque`, and `CACHE_OVERHEAD` prices exactly +`FRAME_SLOTS` (4) of its slots, the capacity `VecDeque` rounds the first frame +up to. Two gaps follow: + +- The deque grows geometrically past those four slots, and every slot beyond + them is `size_of::()` the pool never sees. It only bites where many + small frames share one group (chat, telemetry); a group of kilobyte video + frames is dominated by payload. +- `GroupState::release` (on abort, too-large, or a dropped producer) calls + `frames.clear()`, which keeps the capacity, then zeroes `cache` and clears + the charge. A consumer still holding the group pins those slots uncharged. + Dropping the deque's storage on release is likely enough here. + +The fix is not a bigger constant. The charge has to follow the deque's +capacity: charge the growth at each `charge.add` site (`write_frame`, the +`write_frames` loop, `create_frame`, and `create_frame_owned`), keeping +`FRAME_SLOTS` as the part `CACHE_OVERHEAD` already paid. + +Decide what `MAX_CACHE_BYTES` compares against before touching any of it. +`GroupState::would_overflow` reads `cache` as a payload limit, and the tests +and its doc ("maximum total size of frames") read it that way, so folding +slots into `cache` silently changes the per-group ceiling. Either keep a +separate payload counter for that check or restate the limit; do not let one +field mean both. + +`rs/moq-net/tests/group_charge.rs` weighs the process with a counting +allocator and is the place to prove it: add a many-small-frames shape beside +the one-frame-per-group case, and a unit test that crosses a deque growth +boundary and asserts the pool charge moved. + +Found by CodeRabbit on #3523, which fixed the one-frame-per-group undercount +that was OOM-killing relays serving chat, and deliberately left out of it. + +Public API: none, unless `MAX_CACHE_BYTES` is restated. Wire: none. + +## Related + +- [Relay memory](/quest/m1/relay-memory.md) - the per-announcement half of the same question, whose figures also predate the current accounting diff --git a/quest/m1/go-mirror-delivery.md b/quest/m1/go-mirror-delivery.md new file mode 100644 index 0000000000..df439a1d7b --- /dev/null +++ b/quest/m1/go-mirror-delivery.md @@ -0,0 +1,27 @@ +# [M] Go mirror delivery + +## Goal + +A recommendation, with a prototype, for delivering the Go binding's +staticlibs without committing them to git. `moq-dev/moq-go-ffi` commits +them straight into git today, at 60.2 MiB for linux, 52.7 for windows, and +39.1 for darwin. That puts the largest file at 60% of GitHub's 100 MiB per-file +limit, and each release adds about 210 MiB of history. + +## Plan + +- Evaluate what keeps `go get` working without a separate download step, and + what doesn't: + - one module per platform + - history that squashes or orphans old releases + - a module proxy we host + - release assets fetched by a build step + Git LFS is out, because the Go module proxy doesn't serve LFS objects. +- The release profile quest shrinks the staticlibs (121 to 41 MiB on macOS), + which buys headroom but doesn't stop the history growth. +- Deliverable: the chosen approach recorded here, and a follow-up quest to + implement it. Split this if the prototype turns out large. + +## Related + +- [Release profile](/quest/m1/release-profile.md) - shrinks what the mirror carries diff --git a/quest/m1/gpu-ci.md b/quest/m1/gpu-ci.md new file mode 100644 index 0000000000..64bacaeeec --- /dev/null +++ b/quest/m1/gpu-ci.md @@ -0,0 +1,61 @@ +# [S] NVIDIA tests run on real hardware + +## Goal + +The NVDEC, NVENC, and CUDA tests run nightly on the maintainer's Linux host +(RTX 3070 Ti) instead of passing without a GPU on hosted runners, and a local +`just rs nvidia` runs them against the host driver instead of silently +skipping inside the Nix shell. + +## Plan + +- Why they skip: the driver libraries are loaded at runtime (`libcuda` by + cudarc, `libnvidia-encode` in `rs/moq-nvenc/src/safe/api.rs`, `libnvcuvid` + in `rs/moq-nvenc/src/cuvid.rs`), and the tests return early when they are + missing (`hw_available` in `rs/moq-video/src/decode/backend/nvdec.rs`). The + Nix shell's loader path lacks Ubuntu's `/usr/lib/x86_64-linux-gnu`. +- Select every test that needs the GPU, not only names containing `nvdec`, + `nvenc`, or `cuda`: `safe::session::tests::failed_submission_releases_the_session` + in `rs/moq-nvenc` needs hardware and matches none of them. Find them by + their driver probes (`hw_available`, `driver_libs_present`, `Api::get` and + friends in `moq-nvenc` and `moq-video`). Recommendation: follow the + existing `#[ignore = "requires ..."]` convention (as `frame/vulkan_test.rs` + does) and put them in `nvidia` test modules, so hosted CI reports them + ignored instead of passed and one filter, `--run-ignored only -E + 'test(/::nvidia::/)'`, selects them all without the other ignored hardware + tests (Android, D3D11, PipeWire). Inside that selection a missing GPU fails + the test instead of returning early. Keep the no-driver tests + (`missing_driver_errors_instead_of_panicking`) outside it. +- `just rs nvidia`, a one-line recipe over `sh/rs/nvidia.sh` in the tooling + line's `sh//` layout: symlink only those three libraries (by + soname) from `/usr/lib/x86_64-linux-gnu` into a private directory, put that + on `LD_LIBRARY_PATH`, and run that selection. Fail when a library is missing + instead of skipping. `just rs vulkan-cuda` (`sh/rs/vulkan-cuda.sh`) puts the + whole host driver directory on the path, which lets host libraries shadow + the Nix ones; fold it into this script and recipe, since its `vulkan_cuda_` + tests are the same kind. Those also need + the Vulkan loader to find the host NVIDIA ICD: point it at the ICD manifest + and expose the driver libraries it names, or keep them in their own recipe. +- Nightly: a job in `.github/workflows/nightly.yml` runs `nix develop + --command just rs nvidia` on the self-hosted runner, a recipe and not a + script path, like every other workflow step. A self-hosted runner on a public repository must + never run untrusted code: only `schedule` and `workflow_dispatch`, with the + job gated to `refs/heads/main`, never `pull_request`; a dedicated label only + this job selects; read-only `permissions`. Read GitHub's self-hosted runner + hardening guidance before wiring it. +- Share the runner with the io_uring one that #4132 plans + (`quest/m1/uring-runner.md` on the drain line, which wants a 6.12+ kernel on + the same host): one registration and one security posture, a label per + capability. Whichever quest lands second reuses the first's job shape. + +Public API: none. Wire: none. + +## Required + +- [Tooling](/quest/m1/tooling/README.md) - the `sh/` script layout and recipe-only workflows this follows +- A self-hosted runner is registered for moq-dev/moq on the maintainer's host, with the NVIDIA driver + +## Related + +- [Video hardware validation](/quest/m3/video-hardware.md) - hardware paths nothing runs yet +- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - on-demand jobs on hardware hosts, a broader contract than a nightly diff --git a/quest/m1/hls-discontinuity-sequence.md b/quest/m1/hls-discontinuity-sequence.md new file mode 100644 index 0000000000..a191a9e2aa --- /dev/null +++ b/quest/m1/hls-discontinuity-sequence.md @@ -0,0 +1,37 @@ +# [S] moq-hls segments carry the absolute discontinuity sequence + +## Goal + +Every `moq_hls::export::Segment` reports the discontinuity sequence it belongs +to, the value a recorder writes as `EXT-X-DISCONTINUITY-SEQUENCE`, so two +cursors on sibling renditions agree no matter when each was created. On `dev`, +`Segment::discontinuity` is a `u64` count of breaks since the cursor's +previous segment ([#4068](https://github.com/moq-dev/moq/pull/4068)). A cursor +created after its rendition rebinds starts its count from its own first row, +so it disagrees with a sibling on the baseline. + +## Plan + +Decided: + +- Report the absolute sequence the timeline fanout already stamps on each + row, instead of the difference between rows. A recorder writes + `EXT-X-DISCONTINUITY-SEQUENCE` from the first segment and an + `EXT-X-DISCONTINUITY` wherever the value changes. +- Land it on `dev` before the next moq-hls release, so it ships in the same + breaking release as the existing `bool` to `u64` change rather than + breaking the field twice. + +Guidance: + +- `Position` in `export/segments.rs` then only tracks `after`; the + skip/emit baseline logic goes away. Check the serve path's playlist + rendering (`rendition.rs`, `playlist.rs`) already derives its tags from the + same stamp. +- The sequence is absolute within one `Broadcaster`. Document what a recorder + should do when its broadcaster is rebuilt and the sequence restarts. +- Update the field doc and the tests that assert per-cursor counts; add one + where a cursor created after a rebind reports the same sequence as a cursor + that has run since the start. +- moq.pro's recorder and index store the count today; note the change for its + pin bump. diff --git a/quest/m1/hls-linger.md b/quest/m1/hls-linger.md new file mode 100644 index 0000000000..9da2c54d1c --- /dev/null +++ b/quest/m1/hls-linger.md @@ -0,0 +1,41 @@ +# [S] moq-hls keeps an ended broadcast for its playlist window + +## Goal + +A player polling `moq_hls::Server` can finish the last segments of a broadcast +that just ended, and a republish of the same name takes over cleanly. Today +`Server` evicts a broadcaster as soon as its broadcast closes, so the playlist +404s mid-window. The moq.pro edge works around it with its own pool on top of +`Broadcaster` that holds an ended broadcast for the playlist window plus a +grace period and checks for a republish +([#3964](https://github.com/moq-dev/moq/pull/3964)). Once moq-hls owns this, +the edge deletes its pool. + +## Plan + +Decided: moq-hls owns the retention through an `export::Config` field (the +playlist window plus a grace period), so every embedder gets it and the edge +has nothing to duplicate. `Config` is `#[non_exhaustive]`, so the field is +additive on `main`. + +Guidance: + +- The eviction lives in `server/mod.rs` (`evict_closed` and the + `is_closed` checks in `Server::broadcaster`). An ended broadcaster keeps + serving its final playlist, marked ended if the renditions know it, until + the linger expires. +- A republish during the linger replaces the ended broadcaster. Decide + whether a request for the name resolves the new broadcast first and falls + back to the ended one only when nothing is announced; match what the edge + pool does today. +- Segments are fetched from the relay cache on request, so the linger is only + useful within the cache's retention; say so on the field, as `window` does. +- A zero linger keeps today's behavior. Pick the default with the edge's + current value in mind. +- Tests: a request inside the linger after close still gets the playlist and + a cached segment; after it expires the name 404s; a republish inside the + linger is served. + +## Related + +- [Export linger](/quest/m1/export-linger.md) - the CLI exporter's wait for a broadcast to return; same idea, separate code diff --git a/quest/m1/ietf-uni-stream-types.md b/quest/m1/ietf-uni-stream-types.md index 9efb7e0ed5..6b0ca089c9 100644 --- a/quest/m1/ietf-uni-stream-types.md +++ b/quest/m1/ietf-uni-stream-types.md @@ -8,19 +8,17 @@ so it ships on main. ## Plan -`rs/moq-net/src/ietf/session.rs` routes every non-SETUP uni stream to -`run_uni_group` (`:708`), which rejects padding and unknown types alike while +`run_unis` in `rs/moq-net/src/ietf/session.rs` routes every non-SETUP uni +stream to `run_uni_group`, which rejects padding and unknown types alike while leaving the session alive. That stream-only rejection reaches the wire as -INTERNAL_ERROR on both branches, because nothing registers a code for it: on -dev the handler maps a session-scoped error to `StreamError::Internal` and -aborts the reader (`:697-704`); on main it is `reader.stop(to_stream_code(&err))` -(`:583` there), which falls through to INTERNAL_ERROR the same way. +INTERNAL_ERROR, because nothing registers a code for it: the handler maps a +session-scoped error to `StreamError::Internal` and aborts the reader. draft-21 settles what each stream type means: a stream whose type the endpoint does not recognize MUST close the session, and a padding stream (type 0x132B3E28) MUST be discarded, which an endpoint may do by cancelling -it. The tree negotiates up to draft-20 (`rs/moq-net/src/ietf/version.rs:12`), -so apply that split to every supported draft and check the earlier ones for +it. The tree negotiates drafts 14 through 22 (`ietf::Version` in +`rs/moq-net/src/ietf/version.rs`), so apply that split to every supported draft and check the earlier ones for the padding type value and whether draining is required. - Classify stream types before spawning a group handler. Handle PADDING per @@ -28,15 +26,16 @@ the padding type value and whether draining is required. stream-only code, and propagate a genuinely unknown type to the session driver as a protocol violation. Keep ordinary group failures scoped to their streams. -- The test `unknown_uni_type_does_not_claim_the_session_closed` - (`session.rs:1267`) asserts the current behavior, an INTERNAL_ERROR stop and +- The test `unknown_uni_type_does_not_claim_the_session_closed` in + `session.rs` asserts the current behavior, an INTERNAL_ERROR stop and no session close, and flips: an unknown type now closes the session and stops nothing on its own. - Add a padding test asserting a stream-only cancel with no session close, and - keep `a_group_for_a_retired_alias_is_stopped_with_cancelled` (`:1255`), which + keep `a_group_for_a_retired_alias_is_stopped_with_cancelled`, which pins that a dropped group never closes the session. Consult [draft-21 section 11.5](https://www.ietf.org/archive/id/draft-ietf-moq-transport-21.html) for the wording, and [draft-19 section 3.4 and section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-19.html) plus [draft-20 section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-20.html) -for the drafts the tree negotiates. +and [draft-22](https://www.ietf.org/archive/id/draft-ietf-moq-transport-22.html) +for the other drafts the tree negotiates. diff --git a/quest/m1/interop-flakes.md b/quest/m1/interop-flakes.md deleted file mode 100644 index 679c0344cf..0000000000 --- a/quest/m1/interop-flakes.md +++ /dev/null @@ -1,23 +0,0 @@ -# [S] Interop harness runs clean in parallel - -## Goal - -`just test interop --all` passes reliably while other harness runs share the -machine. Two known flakes: concurrent Nix shells reserve the same port because -each keeps its reservations under its own `TMPDIR`, and the browser driver's -pause click is sometimes blocked by the canvas (`js -> js`). - -## Plan - -- Port reservations live under one root shared by every shell (not - `TMPDIR`), or ports come from the OS (bind to 0 and pass the result). Prefer - whichever removes the reservation file entirely. -- The pause control is driven in a way the canvas cannot intercept (an API or - keyboard path, or waiting for the element to be actionable), not a retry. -- Prove it by running two `--all` harnesses at once, several times. - -Public API: none. Wire: none. - -## Related - -- [#4181](https://github.com/moq-dev/moq/pull/4181) - where both flakes were seen diff --git a/quest/m1/iroh-lite-wip.md b/quest/m1/iroh-lite-wip.md new file mode 100644 index 0000000000..f6be7e23dc --- /dev/null +++ b/quest/m1/iroh-lite-wip.md @@ -0,0 +1,31 @@ +# [XS] iroh honors the configured versions + +## Goal + +An `iroh://` client or listener configured with `moq-lite-07-wip` negotiates +it, as `https://`, `moqt://`, TCP, and Unix sockets already do. A version +list that iroh cannot offer is refused at startup, never silently replaced. + +## Plan + +https://github.com/moq-dev/moq/pull/4148 took `moq-lite-07-wip` out of +`moq_net::ALPNS` so it is opt-in only, but `rs/moq-tokio/src/iroh.rs` builds +its listener ALPNs, its dial offers, and its H3 subprotocols from that +constant rather than the configured `Versions` (`versions.alpns()`, which +`noq.rs`, `server.rs`, and `client.rs` use). So the opt-in is accepted by +config and then ignored on iroh. Thread the configured versions through +instead. + +`moq-ffi`'s transport (`rs/moq-ffi/src/transport.rs`) also offers +`moq_net::ALPNS` directly; fix it here too. The relay's WebSocket listener +already honors the configured versions. Scope is those two files. + +Decided by the maintainer: the finalized lite-07 ALPN stays `moq-lite-07`, +as `drafts/draft-lcurley-moq-lite.md` already says. Peers from the yanked +0.3.2 / 0.15.3 releases advertise `moq-lite-07` with an older framing, and +could land on it with a finalized peer; the maintainer accepts that risk +rather than burn the identifier. Nothing in this quest changes the ALPN. + +## Related + +- [iroh opt-in for moq-relay](/quest/m1/relay-iroh-opt-in.md) - a separate change: whether the relay builds iroh at all diff --git a/quest/m1/js-bundle-trims.md b/quest/m1/js-bundle-trims.md new file mode 100644 index 0000000000..a34822567b --- /dev/null +++ b/quest/m1/js-bundle-trims.md @@ -0,0 +1,47 @@ +# [M] JS bundle trims + +## Goal + +The watch and publish elements stop shipping bytes a consumer's bundler +cannot remove. Measured in bundled, minified form (2026-09-26): + +- The inlined worklets are built unminified, and code inside a string can't + be minified by the consumer. The watch render worklet is 25.9 KB against + 10.1 KB minified, and the publish capture worklet is 4.7 KB against 2.3 KB. +- `bowser` costs 37 KB for three checks in + `js/net/src/connection/browser.ts`: the WebKit engine, iOS, and Firefox 153 + or later. +- `@moq/flate` imports all of pako (42 KB) where the deflate and inflate + halves have their own entry points. +- `@moq/qmux` (39 KB, the WebSocket fallback) and `media-captions` (15 KB, + watch only) load eagerly even when unused. + +## Plan + +Decided in planning: all four trims are in scope. Mediabunny is its own +quest. + +Guidance: + +- Worklets: minify them in `js/common/vite-plugin-worklet.ts`. +- bowser: write a small user-agent check with unit tests over real UA + strings. Chrome's UA contains `AppleWebKit` too, and iPadOS Safari reports + macOS, so test Chrome and Edge on macOS, iPadOS Safari, iOS Chrome, and + Firefox 152 and 153. +- pako: measure before keeping the change. If a caller needs both halves, + the split buys nothing. +- Lazy qmux: if connect races WebSocket against WebTransport, a lazy import + puts module loading on the fallback path. Measure the connect time it adds, + and drop this trim if the fallback gets noticeably slower. +- Report the before and after first-load sizes in the PR. +- Add a CI check that imports the built `@moq/watch` dist outside a browser + (the `bun -e 'await import("./dist/index.js")'` that verified + [#4217](https://github.com/moq-dev/moq/pull/4217)). Unit tests run on + `src`, so a bundled browser-only dependency that breaks Node, Bun, or SSR + imports only shows up in the dist, and these trims move exactly those + imports around. Cover `@moq/publish` the same way if it is cheap. + +## Related + +- [Publish lazy file source](/quest/m1/publish-lazy-file.md) - the largest JS saving, landed separately +- [Size report](/quest/m1/size-report.md) - tracks these entries nightly diff --git a/quest/m1/js-dops-pre-skip.md b/quest/m1/js-dops-pre-skip.md new file mode 100644 index 0000000000..273669a0f9 --- /dev/null +++ b/quest/m1/js-dops-pre-skip.md @@ -0,0 +1,12 @@ +# [XS] js/hang dOps without an invented pre-skip + +## Goal + +`js/hang` CMAF encoding stops writing a hard-coded 312-sample pre-skip into +`dOps` when the track has no description, so a remuxed track never trims audio +its encoder did not ask to trim. + +## Plan + +Without an OpusHead there is no known pre-skip; write 0 or refuse, whichever +matches how the Rust exporter (`synthesize_audio_trak`) handles the same case. diff --git a/quest/m1/js-group-guard.md b/quest/m1/js-group-guard.md deleted file mode 100644 index eca7b3effb..0000000000 --- a/quest/m1/js-group-guard.md +++ /dev/null @@ -1,24 +0,0 @@ -# [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-ietf-datagram.md b/quest/m1/js-ietf-datagram.md new file mode 100644 index 0000000000..db210e7b25 --- /dev/null +++ b/quest/m1/js-ietf-datagram.md @@ -0,0 +1,29 @@ +# [M] @moq/net datagrams over moq-transport + +## Goal + +A `@moq/net` session on moq-transport sends and receives datagram groups as +`OBJECT_DATAGRAM`, one object per group with its sequence kept, as Rust does. +A datagram track crosses IETF between Rust and JS in both directions in +`just test interop`. + +## Plan + +- JS routes datagrams on moq-lite only (`js/net/src/lite/datagram.ts`, + `runDatagrams` from lite-05); `js/net/src/ietf/` reads and writes none. + Mirror the lite path on the IETF session and the Rust mapping from #4274: + receive decodes `OBJECT_DATAGRAM` and inserts on the aliased subscription's + track; send writes object 0 with END_OF_GROUP, the explicit publisher + priority, and the timestamp property when the track has a timescale. +- Keep Rust's edges: an Object ID other than 0, a non-Normal status, or an + unbound alias is dropped; a malformed Type closes the session. Rust covers + drafts 14 and later, whose Type flags differ between 14 and 15+; decide + what draft 07, which JS also speaks, does. +- Add IETF datagram cases beside the lite ones in the interop harness. + +Public API: none expected. Wire: `@moq/net` moq-transport sessions send and +accept `OBJECT_DATAGRAM` as the drafts define; no project draft changes. + +## Required + +- [moxygen interop](/quest/m1/moxygen/README.md) - its datagram groups quest (done on the line) is the Rust side this mirrors and interops with diff --git a/quest/m1/js-ranked.md b/quest/m1/js-ranked.md new file mode 100644 index 0000000000..c7f2131e89 --- /dev/null +++ b/quest/m1/js-ranked.md @@ -0,0 +1,14 @@ +# [S] JS rendition ranking + +## Goal + +`@moq/hang` ranks video renditions the same way as Rust's +`hang::catalog::Video::ranked` (largest picture, then highest bitrate, ties in +name order), and `@moq/watch`'s fallback pick uses it, so the browser and the +native egresses can't drift apart. + +## Plan + +Mirror the Rust name and semantics. `bestRendition` in `js/watch` already +matches today; replace it rather than keep two copies. Check whether the +`byDimensions`/`byBitrate` filters can reuse the same ordering. diff --git a/quest/m1/js-subscribe-abandonment.md b/quest/m1/js-subscribe-abandonment.md index df3ed49137..771a8da131 100644 --- a/quest/m1/js-subscribe-abandonment.md +++ b/quest/m1/js-subscribe-abandonment.md @@ -9,13 +9,15 @@ of receiving an abandonment error. The setup path is the same on main, so the fix lands there. -- `waitAbandoned` (`js/net/src/ietf/subscriber.ts:423-428`) checks demand and - resolves through `Promise.race`; another microtask can attach a viewer before - the catch closes the producer at `:449`. Reproduce that ordering in - `js/net/src/ietf/subscriber.test.ts`: the case at `:673` covers only the - established serving loop. +- `waitAbandoned` (in `#runSubscribe`, `js/net/src/ietf/subscriber.ts`) + checks demand and resolves through `race`; another microtask can attach a + viewer before the catch awaits `sessionCause` and rejects the request. + Reproduce that ordering in `js/net/src/ietf/subscriber.test.ts`: "returning + demand survives a blocked unsubscribe" covers only the established serving + loop. - Recheck demand and commit the close in the same synchronous continuation, - the way the serving loop does (`:511-523`). When demand returns, keep the + the way the serving loop after SUBSCRIBE_OK does (its `producer.used` + re-check before `producer.close()`). When demand returns, keep the existing setup operation and timeout budget. - Cover abandonment before SUBSCRIBE_OK, demand returning before the commit, and late setup completion. Verify cancellation and alias cleanup still happen diff --git a/quest/m1/js-timeline-scans.md b/quest/m1/js-timeline-scans.md deleted file mode 100644 index b46d4c16c7..0000000000 --- a/quest/m1/js-timeline-scans.md +++ /dev/null @@ -1,33 +0,0 @@ -# [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/json-compressed-gate-rs.md b/quest/m1/json-compressed-gate-rs.md new file mode 100644 index 0000000000..0e84456b81 --- /dev/null +++ b/quest/m1/json-compressed-gate-rs.md @@ -0,0 +1,17 @@ +# [XS] Rust test for the compressed snapshot gate + +## Goal + +`rs/moq-json`'s snapshot encoder has a test proving that a delta is admitted +by its encoded size, not its plaintext: a sync-flushed DEFLATE frame can come +out larger than its input, so a plaintext gate lets a patch overflow the +group and evict the snapshot a late joiner needs. The JS encoder gained this +test in the PR that retired `quest/m1/test-flakes.md`. + +## Plan + +- The gate compares against `moq_net::group::MAX_CACHE_BYTES`, which is too + large to reach cheaply. Mirror the JS approach: a crate-private budget the + test can shrink, so it measures real frames and sets a budget between the + patch's plaintext and encoded sizes. +- Confirm the test fails when the gate measures the plaintext. diff --git a/quest/m1/json-rolls-snapshot-test.md b/quest/m1/json-rolls-snapshot-test.md new file mode 100644 index 0000000000..653d9eba4c --- /dev/null +++ b/quest/m1/json-rolls-snapshot-test.md @@ -0,0 +1,14 @@ +# [XS] Shrink the JS snapshot overflow test + +## Goal + +The `js/json` test "a delta that would overflow the snapshot rolls a new one +instead" stops building two ~19 MiB values (about 1 s idle, several under +load) and exercises the same roll with a few bytes, through the encoder's +internal `maxGroupBytes` budget. + +## Plan + +- Keep one assertion that the default budget is moq-net's group cache limit, + so the shrunk test doesn't hide a drift between the two. +- Confirm the test fails when the gate is removed. diff --git a/quest/m1/kio-waiter-lost.md b/quest/m1/kio-waiter-lost.md new file mode 100644 index 0000000000..ae4e29321b --- /dev/null +++ b/quest/m1/kio-waiter-lost.md @@ -0,0 +1,26 @@ +# [XS] kio waiter overflow stays idempotent + +## Goal + +A `kio::Waiter` that has registered on more than 8 lists still registers for +free on each list it recorded, so a retained standalone `Waiter` polled +repeatedly no longer appends a duplicate entry to those lists every time. + +## Plan + +`Waiter::record` in `rs/kio/src/waiter.rs` returns early once `lost` is set, +before it matches the list's tag against the recorded slots. `Park` retires +such a waiter, so it never notices, but `Waiter` is public and a caller that +keeps one across polls grows every list it sits on, one live entry per +register until the list drains, and gets a duplicate wake for each. + +Match the recorded tags first and only then fall back on `lost`. This was +written with the regression test `overflow_keeps_recorded_lists_idempotent` +during https://github.com/moq-dev/moq/pull/4240, which squash-merged before +it was pushed, so it needs rewriting. Keep the recorded-tag probe the tight +common-case loop it is today; `rs/kio/benches/waiter.rs` shows whether it +moved. + +A list past the 8 slots is unrecorded and still has to append on every +register, which is the pre-#4240 behavior. Say so on `Waiter::register` so a +standalone caller knows the bound, rather than growing the record array. diff --git a/quest/m1/kt-jvm-exit.md b/quest/m1/kt-jvm-exit.md index 229659840f..71fbe5e115 100644 --- a/quest/m1/kt-jvm-exit.md +++ b/quest/m1/kt-jvm-exit.md @@ -34,7 +34,3 @@ out of scope: it kills the process rather than destroying the VM. exit code. Run it in the existing `kt` CI job. Public API: none; the hook is internal to the binding. Wire: none. - -## Related - -- [moq-c shutdown](/quest/m1/libmoq-shutdown.md) - the same hazard class for the C ABI and the OBS plugin diff --git a/quest/m1/ladder/README.md b/quest/m1/ladder/README.md index 20dae78a47..0c242cc396 100644 --- a/quest/m1/ladder/README.md +++ b/quest/m1/ladder/README.md @@ -27,9 +27,13 @@ What remains is the publisher side. The allocator estimate by `track::Info::priority`, filling a tier before the next sees a bit and splitting max-min fair within one. A controller that assigns descending priorities down the ladder gets correct allocation from that -alone. Send order is the same number, not a separate question: -`Priority::cmp` (`rs/moq-net/src/lite/priority.rs`) ranks by track priority -first, so the same number decides what to produce and what to send first; +alone. Send order is a different number today: `Priority::cmp` +(`rs/moq-net/src/lite/priority.rs`) ranks first by `Priority.track`, which is +the subscriber's priority from SUBSCRIBE (`msg.priority` in +`rs/moq-net/src/lite/publisher.rs`), not the publisher's `Info::priority`. +The controller quest makes the publisher's number the tiebreak after it, so +the same number decides what to produce and, among equal subscriber +priorities, what to send first; [Scope track priority](/quest/m1/track-priority-scope.md) settles what that ranking means on the first mile versus a cluster session before the controller depends on it. @@ -67,7 +71,7 @@ bitrate; closing or removing stalled tracks; an application-level [#2734](https://github.com/moq-dev/moq/issues/2734)); rebuilding unsupported encoders on every target change. -## Quests +## Required - [Controller](/quest/m1/ladder/controller.md) - one controller owns every rung's share, target, stalled state, and send order diff --git a/quest/m1/legacy-end-overshoot.md b/quest/m1/legacy-end-overshoot.md new file mode 100644 index 0000000000..77cff3dc58 --- /dev/null +++ b/quest/m1/legacy-end-overshoot.md @@ -0,0 +1,34 @@ +# [S] Browser playback survives an estimated group end + +## Goal + +A `@moq/watch` subscriber never aborts a browser-published track with "group +timestamp is below the live edge" when the publisher's next group starts +inside the previous group's estimated end. + +## Plan + +Seen once in `js -> js` while two interop matrices ran side by side: the +subscriber logged `skipping covered group: 0 -> 1`, then aborted the video +track with that error, one second into the cell. + +Likely cause, not yet reproduced in a unit test: the Legacy producer in +`js/hang/src/container/legacy.ts` closes a group without an explicit end (for +example `cut()` when the encoder pauses for lack of demand) by estimating +`end = last frame + interval` and writing that as the group's end marker. Its +own live edge stays at the last frame, so it accepts a resuming keyframe that +lands before the estimate. The consumer (`js/hang/src/container/consumer.ts`) +takes the live edge from the end marker, so that keyframe's group reads as +below it and the track aborts. The consumer's covered-group skip tolerates the +same overlap the live-edge check rejects. + +- Reproduce with a mocked clock: cut a group, then resume with a keyframe + between the last frame and the estimated end. +- Decide which side is wrong: the producer's estimate reaching past what it + will refuse, or the consumer treating an estimated end as a hard edge. + Fix it there and keep the other side's rule consistent with it. +- Check the Rust `moq-mux` producer for the same estimate. + +## Related + +- [More tests under load](/quest/m1/test-flakes-2.md) - other load-only failures, fixed at the cause diff --git a/quest/m1/libmoq-fetch.md b/quest/m1/libmoq-fetch.md deleted file mode 100644 index c2d47b411a..0000000000 --- a/quest/m1/libmoq-fetch.md +++ /dev/null @@ -1,18 +0,0 @@ -# [S] moq-c: fetch one cached group - -## Goal - -A C embedder can fetch one cached group by sequence through the existing frame, -handle, and terminal-status conventions. The new entry point is additive. - -## Plan - -Mirror `MoqTrackConsumer::fetch_group` from `rs/moq-ffi/src/consumer.rs` in -`rs/moq-c`, supporting raw and container-decoded delivery as the FFI does. -Specify ownership, cancellation, cache misses, and terminal delivery using the -existing consume contracts. Test a hit, miss, and cancelled fetch from a C caller. - -Regenerate `moq.h` and update `doc/lib/c/index.md`, whose capability list already -claims group fetch. Decoder output configuration landed separately on the dev line. -The `moq_group_request_*` tests in `rs/moq-c/src/test.rs` fetch from Rust for -lack of this entry point; switch them to it. diff --git a/quest/m1/libmoq-shutdown.md b/quest/m1/libmoq-shutdown.md deleted file mode 100644 index c50435b5b4..0000000000 --- a/quest/m1/libmoq-shutdown.md +++ /dev/null @@ -1,52 +0,0 @@ -# [M] moq-c can be stopped before its host unloads it - -## Goal - -OBS exits without a crash whether a moq output is running, was just stopped, -or a source still has terminal callbacks outstanding. moq-c parks a -process-wide `moq-c` thread in `block_on` on first use and has no way to -stop it, while OBS `dlclose`s each plugin at shutdown; the plugin links -moq-c statically, so the thread's code is unmapped under it. Any host that -unloads the library has the same exposure. - -## Plan - -Starts on `main`; moq-c's runtime (`rs/moq-c/src/ffi.rs`) is the same on -both branches and the change is additive. - -- Reproduce first: exit OBS with the plugin loaded and an output running, - then again right after stopping it, and again with a source whose - `moq_source_destroy` hit its two-second backstop. Record which of these - crashes, and how, in the PR before changing anything. -- Add `moq_shutdown()` to the C ABI, mirroring `moq_ffi_shutdown` in - `rs/moq-ffi`: it stops and joins the `moq-c` thread. Pending calls resolve - as cancelled, the terminal callback for every still-open handle fires with - a cancelled status before it returns, and any later call returns an error - code rather than restarting the runtime. It is idempotent and must be - called from a host thread, never from a moq-c callback. Native codec and - capture threads belong to their handles and end with them; this call does - not reach into them. -- The OBS wiring (`obs_module_unload` calling the shutdown after the outputs - and sources are destroyed) belongs to [OBS migration](/quest/m1/cpp/obs.md), - where the plugin reaches moq-ffi through the generated C++ and calls - `moq_ffi_shutdown`; this quest gives the plain-C ABI the same call for the - hosts that stay on moq-c. -- Regression tests: a `rs/moq-c/c-tests` fixture that builds moq-c as a - cdylib, `dlopen`s it, opens a session, and `dlclose`s once without - `moq_shutdown` (must fail, proving the hazard) and once with it (clean - exit, thread gone); a `rs/moq-c` unit test that pending and later calls - resolve cancelled and open handles close without panicking afterwards - (mirror `rs/moq-ffi/src/test.rs::shutdown_cancels_and_drops_cleanly`, - in a child process since the stop is process-wide). Wire the new fixture - into `just rs c-tests`. -- Cross-package sync: `moq.h` is cbindgen-generated from the Rust doc - comment; update `doc/lib/c` (the handle and callback contract) and - the Go bindings regenerate from `moq.h` and need no - wrapper: `os.Exit` ends the process without tearing a host runtime down - under the thread. - -Public API: additive on the moq-c C ABI (`moq_shutdown`). Wire: none. - -## Related - -- [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - the same hazard class for the moq-ffi bindings diff --git a/quest/m1/lite-count-settle.md b/quest/m1/lite-count-settle.md deleted file mode 100644 index 0ff1f18a71..0000000000 --- a/quest/m1/lite-count-settle.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] lite-07 subscribers settle on the stream count - -## Goal - -On moq-lite-07, Rust and JS subscribers stop waiting for a subscription's tail -once they have read the headers of as many group streams as SUBSCRIBE_END -counts, so a group the publisher skipped or never opened costs no grace. A -late stream below the end is still accepted within the grace, which stays for -a stream reset before its header arrived. lite-05 and -06 keep the DROP -accounting JS already has and Rust track tail adds. - -## Plan - -- Both publishers already send the count, and both subscribers decode it - (`lite::SubscribeEnd::streams` in Rust, `SubscribeEnd.streams` in JS) and - ignore it. -- JS track tail has landed, and its `Tail` already counts streams. Rust track - tail has landed (`rs/moq-net/src/tail.rs`), so lite-07's completion check - becomes "headers read >= Stream Count" after SUBSCRIBE_END, in place of - every sequence from start to end being covered. The Rust subscriber needs - the same count. -- Tests in both languages: a late stream after SUBSCRIBE_END, a skipped group - that settles without the grace, a reset stream, and a count of zero. Add - the Rust-JS case to the track tail interop test. - -## Related - -- [Track tail interop](/quest/m1/track-tail-interop.md) - the Rust-JS case this adds its count check to -- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - makes the count exact by keeping a reset stream's header diff --git a/quest/m1/merge-queue.md b/quest/m1/merge-queue.md new file mode 100644 index 0000000000..d84f2cee10 --- /dev/null +++ b/quest/m1/merge-queue.md @@ -0,0 +1,36 @@ +# [S] Merges go through a merge queue + +## Goal + +A pull request cannot break `main` by merging on checks that ran against an +older `main`. [#4137](https://github.com/moq-dev/moq/pull/4137) did exactly +that: it merged cleanly, but combined with +[#4089](https://github.com/moq-dev/moq/pull/4089) it stopped `moq-ffi` from +compiling on `main` until [#4157](https://github.com/moq-dev/moq/pull/4157) +fixed it. The `main` ruleset requires `Check` and `Test` but not strictly, +and there is no merge queue, so nothing re-runs them on the combined tree. + +## Plan + +- Decided: a GitHub merge queue, not a strict ruleset. Strict status checks + would force every open PR to update and re-run whenever `main` moves; a + queue tests the combination once, at merge time. +- Make the workflows ready: every workflow providing a required check + (today `Check` and `Test` in `.github/workflows/check.yml`) also runs on + `merge_group`. `just ci check|test` runs `sh/dispatch.sh`, which scopes + by diffing against its `BASE` argument, else `origin/$GITHUB_BASE_REF`. A + merge group sets no `GITHUB_BASE_REF`, so pass the group's base + (`github.event.merge_group.base_sha`) as `BASE`. Check the concurrency + group and the `closed`-only skip still behave for queue refs. +- Document it in `CONTRIBUTING.md`: PRs merge through the queue, a + dequeued PR means the combination failed, and how agents enqueue (the + merge skills use `gh pr merge`, which enqueues when a queue is on). +- The ruleset change (enable the queue, choose squash) is the maintainer's + act, after the workflow change lands on `main`. Hand it over with the + settings to use rather than changing it. + +Public API: none. Wire: none. + +## Required + +- [Tooling](/quest/m1/tooling/README.md) - `just ci` and `sh/dispatch.sh`, the entry point the queue runs diff --git a/quest/m1/moqsrc-stop.md b/quest/m1/moqsrc-stop.md new file mode 100644 index 0000000000..d3de732b61 --- /dev/null +++ b/quest/m1/moqsrc-stop.md @@ -0,0 +1,27 @@ +# [S] moqsrc waits for its session to end on stop + +## Goal + +`moqsrc`'s PAUSED to READY transition returns only after its session task and +connection have ended, as `moqsink` does since +[#4074](https://github.com/moq-dev/moq/pull/4074). Today +`SessionController::stop` in `rs/moq-gst/src/source/imp.rs` signals shutdown +and spawns a task to await the join, so an application that sets NULL and +exits right away can race aws-lc's exit destructors against an in-flight +dial, the silent SIGABRT the sink fix closed. + +## Plan + +Blocking needs care: the session task pushes buffers into src pads +(`block_in_place(|| pad.push(buffer))`), and a push blocked downstream while +`stop` waits on the task is a deadlock. Unblock the pads first (set them +flushing, or otherwise make a pending push return) before waiting, the way +GStreamer sources normally stop their streaming threads. + +Reuse the sink's wait: it parks the thread on a waker instead of entering an +executor, and uses `block_in_place` when called from a multi-thread runtime +worker. Consider sharing it between the two elements rather than copying. + +Tests mirroring the sink's: stop returns after the connection closes, stop +from a runtime worker does not deadlock, stop inside another executor does +not panic, and stop while a push is blocked downstream returns. diff --git a/quest/m1/moxygen/README.md b/quest/m1/moxygen/README.md index d7d7e72434..e478e9d030 100644 --- a/quest/m1/moxygen/README.md +++ b/quest/m1/moxygen/README.md @@ -28,7 +28,7 @@ Out of scope, so they are not reopened as bugs: Docs stay inline in the change that makes them stale. No new guide. -## Quests +## Required - [Default track priority](/quest/m1/moxygen/priority.md) - an unset track priority is the midpoint on moq-lite and on IETF, not the least urgent value - [Group FETCH](/quest/m1/moxygen/fetch.md) - an IETF FETCH of whole groups is served from cache or fetched upstream, one group at a time diff --git a/quest/m1/mp4-atom-dops-mapping.md b/quest/m1/mp4-atom-dops-mapping.md new file mode 100644 index 0000000000..3d1f4d818d --- /dev/null +++ b/quest/m1/mp4-atom-dops-mapping.md @@ -0,0 +1,15 @@ +# [S] mp4-atom dOps channel mapping + +## Goal + +A released `mp4-atom` decodes and encodes a `dOps` box with any channel mapping +family, exposing the family and its table (stream count, coupled count, and the +per-channel mapping) instead of refusing a nonzero family. This repository +bumps to that release. + +## Plan + +The work lands in kixelated/mp4-atom. Validate the table against the output +channel count on decode rather than trusting it, and keep family 0 encoding +byte-identical. The bump here is a separate, small step once the release +ships. diff --git a/quest/m1/mux-wasm-target.md b/quest/m1/mux-wasm-target.md deleted file mode 100644 index 74f9054a83..0000000000 --- a/quest/m1/mux-wasm-target.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] moq-mux compiles for wasm32 - -## Goal - -`cargo check -p moq-mux --target wasm32-unknown-unknown` passes. Two things -in the crate compile natively only through workspace feature unification and -break on the wasm target, which is what stops anything above `moq-net` from -reaching the browser through Rust. - -## Plan - -Found by the #2907 spike; both are latent bugs independent of any browser -work. - -- `tokio::time::Instant` in `rs/moq-mux/src/codec/{av1,h264,h265}/split.rs`: - `moq-mux` declares tokio with only the `macros` feature, so this compiles - natively only because another crate turns the runtime on. Use - `web_async::time`, the migration `moq-net` already made. -- `pub trait Stream: Send + 'static` in `rs/moq-mux/src/catalog/stream.rs`: - moq-net's wasm stats types are `Rc>`, so the supertrait cannot - be satisfied. Apply the `MaybeSend` treatment `moq-net` has in - `src/util.rs`. -- Add the crate to the wasm clippy lane in `rs/justfile` beside `moq-wasm` - and `moq-net`, so it stays green. - -Public API: none. Wire: none. - -## Related - -- [Browser through moq-ffi](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the study these blockers were found by diff --git a/quest/m1/nvdec-teardown.md b/quest/m1/nvdec-teardown.md deleted file mode 100644 index cb631e47c5..0000000000 --- a/quest/m1/nvdec-teardown.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Dropping an NVDEC decoder does not crash - -## Goal - -Dropping a `moq-video` NVDEC decoder releases it without a segfault, and the -hardware tests `nvdec_h264_round_trip` and `nvdec_resize_scales_output` pass. - -## Plan - -Both tests die with SIGSEGV on `main` on an RTX 3070 Ti (driver 595.91), while -`nvdec_h265_round_trip` and `nvdec_to_nvenc_zero_copy` pass. The backtrace -ends in `cuEventDestroy_v2`, called by `cuvidDestroyDecoder` from -`::drop` while the test drops the backend. That -`Drop` assumes the caller keeps the CUDA context bound, which nothing -enforces. Suspect the context is not current on the dropping thread, or is -released before the decoder; confirm before fixing. - -Under the Nix dev shell the driver libraries are not on the loader path, so -the NVIDIA tests skip. To run them, expose `libcuda`, `libnvidia-encode`, and -`libnvcuvid` from `/usr/lib/x86_64-linux-gnu` through a directory of symlinks -on `LD_LIBRARY_PATH`. diff --git a/quest/m1/nvenc-keyframe-flag.md b/quest/m1/nvenc-keyframe-flag.md index 142af7c24f..a377f53eac 100644 --- a/quest/m1/nvenc-keyframe-flag.md +++ b/quest/m1/nvenc-keyframe-flag.md @@ -17,7 +17,7 @@ Annex-B slice type. mean what the moq-mux importer treats as a group start (an H.264 IDR, an H.265 IRAP), so check both codecs on hardware. - The NVIDIA tests skip under the Nix shell unless the driver libraries are on - the loader path; see [NVDEC teardown](/quest/m1/nvdec-teardown.md). + the loader path; see [GPU CI](/quest/m1/gpu-ci.md). ## Related diff --git a/quest/m1/obs-moq-video/README.md b/quest/m1/obs-moq-video/README.md index 5711201d50..430136b781 100644 --- a/quest/m1/obs-moq-video/README.md +++ b/quest/m1/obs-moq-video/README.md @@ -2,28 +2,29 @@ ## Goal -Remove the MoQ OBS plugin's dependency on OBS/system FFmpeg ABI versions by decoding subscribed video with moq-video. Add audio playback and publishing with moq-audio, and opt-in video publishing with moq-video. OBS retains scene composition, audio mixing, and output timing. This integration serves MoQ publishing and playback, not general OBS recording or other streaming outputs. +Remove the MoQ OBS plugin's dependency on OBS/system FFmpeg ABI versions by decoding subscribed video with moq-video and subscribed audio with moq-audio. Add audio publishing with moq-audio, and opt-in video publishing with moq-video. OBS retains scene composition, audio mixing, and output timing. This integration serves MoQ publishing and playback, not general OBS recording or other streaming outputs. ## Plan -Portability and FFmpeg removal lead. The current MoQ source uses libavcodec, libavutil, and libswscale for video; it has no audio playback. Its swresample linkage is unused. Video replacement can therefore remove direct FFmpeg dependencies without waiting for audio. The plugin reaches codecs through the generated C++ package over moq-ffi (the migration quest linked below lands first); build `libmoq_ffi` statically with only the codec features needed here; OS frameworks and runtime GPU drivers remain valid dependencies. Verify plugin imports instead of promising a completely static OBS plugin. +Portability and FFmpeg removal lead. The current MoQ source decodes video with libavcodec, libavutil, and libswscale, and audio with libavcodec (`moq_source_decode_audio_frame` in `cpp/obs/src/moq-source.cpp`). Its swresample linkage is unused. FFmpeg linkage goes away only once both the video source replacement and the audio decode replacement land. The stranded audio branch (#3498, merged only into `codex/obs-audio-receive-base`) is abandoned; the audio playback quest replans it on the generated C++. The plugin reaches codecs through the generated C++ package over moq-ffi (the migration quest linked below lands first); build `libmoq_ffi` statically with only the codec features needed here; OS frameworks and runtime GPU drivers remain valid dependencies. Verify plugin imports instead of promising a completely static OBS plugin. Attempt GPU delivery immediately, starting on macOS. Windows and Linux can ship independently. Prefer direct surface reuse, allow GPU conversion/blits, and automatically fall back to CPU delivery when import is unavailable or fails. Stats must show the actual decoder/encoder, delivery path, and fallback reason. Retaining a texture handle is insufficient unless pool ownership and synchronization also prevent reuse while work is in flight. Initial video decoding covers H.264, HEVC, and AV1 where moq-video has an available backend. Unsupported codecs produce an actionable error; do not retain an FFmpeg fallback. VP8/VP9 return through their own follow-up quest. Audio playback covers Opus, AAC-LC, and PCM. -Publishing remains opt-in, with one **Use MoQ encoders** choice for video and audio. Keep the existing OBS encoder mode. Internal OBS encoder adapters call moq-video/moq-audio, preserving OBS's A/V handling and the existing encoded MoQ output. The combined choice is enabled only when both adapters are present. Start with H.264, supported HEVC, and Opus; defer AV1/AAC encoding and PCM publishing UI. Keep bitrate separate from **Low latency** (default), **Balanced**, and **Quality** presets. Presets describe supported buffering/compression controls, not an end-to-end delay promise. +Publishing remains opt-in, with one **Use MoQ encoders** choice for video and audio. Keep the existing OBS encoder mode. Internal OBS encoder adapters call moq-video/moq-audio, preserving OBS's A/V handling and the existing encoded MoQ output. The combined choice is enabled only when both adapters are present. Start with H.264, supported HEVC, and Opus; defer AV1/AAC encoding and PCM publishing UI. Keep bitrate separate from **Low latency**, **Balanced** (default), and **Quality** presets. Presets describe supported buffering/compression controls, not an end-to-end delay promise. -The quests separate portable decoding, platform GPU delivery, audio, and publishing so each can land and be validated independently. moq-ffi's decoded frames own their surface and convert to CPU pixels only on request; a native decode exposes the platform surface as a borrowed view, which each platform quest extends to its own surface type. +The quests separate portable decoding, platform GPU delivery, audio, and publishing so each can land and be validated independently. moq-ffi's decoded frames own their surface and convert to CPU pixels only on request (#4094, on `dev`); a native decode exposes the platform surface as a borrowed view, which each platform quest extends to its own surface type. -## Quests +## Required -- [Video source replacement](/quest/m1/obs-moq-video/source.md) - remove FFmpeg and attempt macOS GPU delivery immediately, with a working CPU fallback on other platforms -- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - add synchronized subscribed audio through moq-audio +- [Video source replacement](/quest/m1/obs-moq-video/source.md) - remove the FFmpeg video decode and attempt macOS GPU delivery immediately, with a working CPU fallback on other platforms +- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - replace the FFmpeg audio decode with moq-audio - [Windows decoded frames](/quest/m1/obs-moq-video/decode-windows.md) - present decoded D3D11 surfaces in OBS without CPU readback - [Linux decoded frames](/quest/m1/obs-moq-video/decode-linux.md) - present supported native decoded surfaces with visible CPU fallback - [Linux bundle](/quest/m1/obs-moq-video/linux-bundle.md) - attach a portable Linux x86_64 tarball to every obs-moq release once FFmpeg is gone - [Encoder presets](/quest/m1/obs-moq-video/presets.md) - define and measure shared low-latency, balanced, and quality policies +- [Preset parity](/quest/m1/obs-moq-video/preset-parity.md) - audio stores and reports its preset like video, defaults to Balanced, and the preset claims hold - [Audio publishing](/quest/m1/obs-moq-video/audio-publish.md) - back an internal OBS Opus encoder with moq-audio - [Video publishing](/quest/m1/obs-moq-video/adapter.md) - back an internal OBS video encoder with moq-video and expose the combined opt-in mode - [Rate control](/quest/m1/obs-moq-video/rate-control.md) - the plugin reserves its bitrate and retunes the OBS encoder to the grant diff --git a/quest/m1/obs-moq-video/adapter.md b/quest/m1/obs-moq-video/adapter.md index 76e050c99c..33845995fe 100644 --- a/quest/m1/obs-moq-video/adapter.md +++ b/quest/m1/obs-moq-video/adapter.md @@ -9,7 +9,7 @@ One opt-in Use MoQ encoders choice publishes OBS video and audio through moq-vid - Register an internal OBS video encoder, backed by `moq_video::encode::Sink`. Retain `MoQOutput::EncodedPacket` and existing catalog handling. Do not replace the output with raw publication: OBS's encoded-output flag is output-wide, and bypassing it would duplicate A/V integration. - Add a clean codec-only moq-ffi encoder type with owned handles and packet draining, extending shared primitives from the audio adapter where appropriate. Avoid backend internals and caller cleanup callbacks. The existing raw-video publishing API couples encoding to publication and is not the packet adapter. - Start with H.264 by default and HEVC where supported. Keep reordering disabled; resolve Annex-B headers/decoder configuration, DTS/PTS and drain semantics explicitly, since Rust encoded output carries only timestamp, payload, and keyframe flag. Do not advertise unsupported AV1 encoding. -- Use the shared Low latency, Balanced, and Quality presets with bitrate separate. Expose a single Use MoQ encoders option only once audio and video adapters both work. Retain the existing OBS encoder selection as an explicit alternative; do not silently switch back to OBS codecs after a MoQ codec failure. +- Use the shared Low latency, Balanced, and Quality presets with bitrate separate, defaulting to Balanced. Expose a single Use MoQ encoders option only once audio and video adapters both work. Retain the existing OBS encoder selection as an explicit alternative; do not silently switch back to OBS codecs after a MoQ codec failure. - Establish bounded submission/packet queues, explicit raw-frame drop behavior, thread confinement, cancellation, late completion, device loss, resize and color metadata. Never block OBS's graphics thread on network backpressure. The CPU path is a correctness/fallback baseline; platform quests establish accelerated input. - Test rejection, saturation, drain, stop during encode, delayed completion, and repeated start/stop. Validate real decoded pixels and audio continuity, matched timestamps, preset reporting, and frame-to-packet latency. Validate the new binding docs, feature combinations, package dependencies and native plugin linking. diff --git a/quest/m1/obs-moq-video/audio-playback.md b/quest/m1/obs-moq-video/audio-playback.md index d7510a8cef..d9b258ea04 100644 --- a/quest/m1/obs-moq-video/audio-playback.md +++ b/quest/m1/obs-moq-video/audio-playback.md @@ -1,15 +1,20 @@ -# [L] Add moq-audio playback to the OBS source +# [L] Replace the OBS source's FFmpeg audio decode with moq-audio ## Goal -Subscribed MoQ audio plays through OBS alongside video using statically linked moq-audio codec code, including Opus, AAC-LC, and PCM. Audio playback can ship independently of video replacement and publishing. +The MoQ source decodes subscribed audio with statically linked moq-audio instead of libavcodec, covering Opus, AAC-LC, and PCM, and keeps its multichannel speaker placement. Audio can ship independently of video replacement and publishing. ## Plan -- The current source is video-only. Reuse the moq-ffi audio consumer (`subscribe_audio`, a `next()` future per frame, cancel on drop), then feed converted PCM to `obs_source_output_audio`. Keep device playback inside OBS; do not enable moq-audio device capture/playback features or open a second audio device. -- Map catalog tracks, sample rates, channel layouts, and timestamps explicitly. Support mono/stereo initially, with explicit rejection or a tested OBS conversion for other layouts. Share the source's media timebase with video, preserve reconnect and rendition changes, and bound audio buffering. `latency_max_ms` controls stalled-group skipping, not desired A/V playout delay. +- Today the source already plays audio: `moq_source_subscribe_audio` in `cpp/obs/src/moq-source.cpp` receives encoded frames through moq-c's `moq_consume_audio`, and `moq_source_decode_audio_frame` decodes them with libavcodec before `obs_source_output_audio`. Replace that decode, not the playback path. +- Decode through the generated C++ package with `MoqBroadcastConsumer::decode_audio` (a `MoqAudioConsumer` with a `next()` future per frame, cancel on drop), then feed its PCM to `obs_source_output_audio`. `decode_audio` accepts Opus and AAC-LC today; add PCM there rather than in the plugin. Keep device playback inside OBS; do not enable moq-audio device capture/playback features or open a second audio device. +- Map catalog tracks, sample rates, channel layouts, and timestamps explicitly. Channel layouts: map each channel count to the default layout moq-audio uses, the WAVE convention (3 is 2.1, 4 is quad, 6 is 5.1, 8 is 7.1), picking the nearest OBS `speaker_layout`, and refuse counts OBS cannot place rather than guess. This replaces `audio_layout_to_speakers`, which maps from FFmpeg layouts, and absorbs the former m2 obs-wave-layout quest. Note the mapping in `doc/bin/obs.md`. +- Share the source's media timebase with video, preserve reconnect and rendition changes, and bound audio buffering. `latency_max_ms` controls stalled-group skipping, not desired A/V playout delay. - Preserve OBS mixer, monitoring, mute, and volume behavior. Release every frame on output, conversion failure, stop, and late completion. Do not hold source state locks across callbacks into OBS. -- Verify audible output and recorded PCM, A/V synchronization with timestamped test media, rate changes, silence, stalls, reconnect, source replacement, and teardown. Exercise Opus, AAC-LC and PCM fixtures, not just callback counts. Update source documentation and Stats. +- Remove the audio FFmpeg includes and codec mapping. Whichever of this and the video source replacement lands second removes the remaining FFmpeg CMake linkage. +- Verify audible output and recorded PCM, A/V synchronization with timestamped test media, rate changes, silence, stalls, reconnect, source replacement, teardown, and a 5.1 fixture placed on the right speakers. Exercise Opus, AAC-LC and PCM fixtures, not just callback counts. Update source documentation and Stats. + +Decided: the earlier receive branch (#3498, merged only into the stranded `codex/obs-audio-receive-base`) is abandoned rather than rebased; this quest starts from the plugin on the generated C++. ## Required @@ -18,3 +23,4 @@ Subscribed MoQ audio plays through OBS alongside video using statically linked m ## Related - [Video source replacement](/quest/m1/obs-moq-video/source.md) - coordinate the shared timestamp and source lifecycle without blocking audio rollout +- [Channel layouts](/quest/m1/audio-codecs/layout.md) - the moq-audio layouts this mapping mirrors diff --git a/quest/m1/obs-moq-video/linux-bundle.md b/quest/m1/obs-moq-video/linux-bundle.md index 099aeb4323..c15a4b68d3 100644 --- a/quest/m1/obs-moq-video/linux-bundle.md +++ b/quest/m1/obs-moq-video/linux-bundle.md @@ -6,15 +6,16 @@ Every `obs-moq-v*` release attaches a Linux x86_64 tarball that loads into a sto ## Plan -- The only reason `obs-build` in `.github/workflows/moq-c.yml` skips Linux is FFmpeg: the source links nix/distro libavcodec, which is not portable. The FFmpeg removal is the blocker; once the plugin is C++ over moq-c plus libobs and Qt6, a Linux build has no extra runtime dependency that OBS itself does not already carry. +- The only reason `obs-build` in `.github/workflows/moq-c.yml` skips Linux is FFmpeg: the source links nix/distro libavcodec for both video and audio, which is not portable. The FFmpeg removal (video source replacement plus audio playback) is the blocker; once the plugin is C++ over moq-ffi plus libobs and Qt6, a Linux build has no extra runtime dependency that OBS itself does not already carry. - Build on `ubuntu-24.04` (glibc 2.39, the floor OBS's own Linux packages target) against the libobs and Qt6 headers OBS's plugin template uses; the template's `.deb` recipe is the reference. Ship the plain archive layout the other platforms use (`obs-moq-*-x86_64-unknown-linux-gnu.tar.gz` with `bin/64bit/obs-moq.so` and `data/`), extractable into `~/.config/obs-studio/plugins/obs-moq/`. A `.deb` is optional and separate. - Flatpak OBS cannot load a plugin from the host filesystem. Verify a real Flatpak install and support it only if the sandbox's runtime ABI matches, documenting the `~/.var/app/com.obsproject.Studio/config/obs-studio/plugins/` path. Otherwise, state plainly that Flatpak is unsupported. -- `cpp/obs/build.sh --target x86_64-unknown-linux-gnu` produces the tarball, and the matrix in `obs-build` gains the row; nothing else in the release pipeline changes. Keep the nightly `just obs ci` compile as the PR gate. +- `cpp/obs/build.sh --target x86_64-unknown-linux-gnu` produces the tarball, and the matrix in `obs-build` gains the row; nothing else in the release pipeline changes. Keep the nightly `just obs ci` compile as the PR gate; #4370 (open) compiles the OBS plugin on every PR, which covers the Linux compile if it lands first. - Verify by loading the tarball into the oldest supported OBS 32 release and current stable on Ubuntu 24.04 and one non-Debian distro (Fedora), publishing and subscribing against a relay, and inspecting the `.so` with `ldd` for nothing beyond libobs, Qt6, glibc, and OS libraries. ## Required -- [Video source replacement](/quest/m1/obs-moq-video/source.md) - removes the FFmpeg linkage that makes a Linux binary non-portable +- [Video source replacement](/quest/m1/obs-moq-video/source.md) - removes the FFmpeg video linkage that makes a Linux binary non-portable +- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - removes the FFmpeg audio linkage ## Related diff --git a/quest/m1/obs-moq-video/macos.md b/quest/m1/obs-moq-video/macos.md index f56120b6c2..aa8265c9be 100644 --- a/quest/m1/obs-moq-video/macos.md +++ b/quest/m1/obs-moq-video/macos.md @@ -9,6 +9,11 @@ An OBS compositor frame reaches moq-video's VideoToolbox encoder without a GPU-t - Inspect OBS's OpenGL compositor, `encode_texture2`, and mac-videotoolbox input path. Determine whether the output allocation is IOSurface-backed and exportable. OBS's encoder currently copies CPU planes into its own pixel buffer; a CVPixelBuffer in moq-video alone does not remove that upstream readback. - Prefer retained IOSurface/CVPixelBuffer storage in the format VideoToolbox accepts. Otherwise prototype GPU color conversion/blit into an IOSurface-backed NV12 pool. Specify GL/Metal/CoreVideo interop, graphics-context thread affinity, completion fences, and when OBS may recycle the source. - Reuse the native PixelBuffer surface and the moq-video VideoToolbox backend. Retain the destination until encoding completes, including dropped submissions and cancellation. Bound the pool and handle resolution/HDR changes and device failure. +- Measure and map the encoder presets on VideoToolbox while on the + hardware. Today it applies real-time, no-reordering controls and reports + `LowLatency` whatever preset was asked; give Balanced and Quality the + controls VideoToolbox has (real-time off, quality or speed priority) + without reordering, and report what took. - Verify no CPU readback using GPU/API traces and copy counters. Compare direct import or GPU blit against the CPU baseline at 1080p60 and 4K where supported. Check decoded color bars and moving timestamps, latency percentiles, audio sync, stop/restart, and long-running pool reuse on Apple hardware. ## Required diff --git a/quest/m1/obs-moq-video/preset-parity.md b/quest/m1/obs-moq-video/preset-parity.md new file mode 100644 index 0000000000..d2c2cd8623 --- /dev/null +++ b/quest/m1/obs-moq-video/preset-parity.md @@ -0,0 +1,47 @@ +# [S] Audio presets mirror video, and the preset claims hold + +## Goal + +The encoder presets from [#4099](https://github.com/moq-dev/moq/pull/4099) +read the same in moq-video and moq-audio, and nothing they promise is false. +The shape is settled; this finishes it: + +- Audio mirrors video: `moq_audio::encode::Settings` stores the preset it was + given and reads it back, and the audio encoder reports an `Applied` the way + `moq_video::encode::Encoder::applied` does. Today audio only has + `Settings::with_preset`, which rewrites `frame_duration` and forgets the + preset. +- The audio default agrees with itself: `Preset::default()` is `LowLatency` + (10 ms), but `Settings::new()` builds 20 ms, which is `Balanced`. +- The `Preset` doc in `rs/moq-video/src/encode/encoder.rs` no longer claims + that no preset reorders frames: V4L2 leaves reordering and queue depth to + the driver and MediaCodec's no-B-frame setting is an unconfirmed hint. Only + `Applied::preset` confirms it. +- `rs/moq-video/examples/encode-presets.rs` scores PSNR against the right + source frames after the encoder skips some (it ignores the `.skipped` + file today), and refuses an unknown preset name instead of printing the + header and exiting 0. + +## Plan + +- Decided: `encode::Preset` and `Applied { preset: Option, controls: + String }` are the API; don't reopen them. Audio already has its `Preset` + and gains the same `Applied` under `moq_audio::encode`. +- The audio enum default follows `Settings::new()`, not the other way + round: its 20 ms is published in moq-audio 0.1.6 and matches the JS + publish path, so `Preset::default()` becomes `Balanced` for audio. OBS + defaults to Balanced as well (decided with the maintainer), so the plugin + rides the published default instead of overriding it. +- Fail loud in the example: an unknown name is an error listing the valid + ones. +- The line PR ([OBS native codecs](/quest/m1/obs-moq-video/README.md)) must + call out the video default changes #4099 made: NVENC P4 to P1, and + openh264 medium to low complexity. +- Known gap: VAAPI reports `LowLatency` whatever was asked, and V4L2 and + MediaCodec report unconfirmed; no quest owns measuring and mapping presets + for them. Media Foundation and VideoToolbox are owned by + [Windows GPU input](/quest/m1/obs-moq-video/windows.md) and + [macOS GPU input](/quest/m1/obs-moq-video/macos.md). + +Public API: additive on moq-audio (the stored preset and its `Applied` +report); the unpublished audio `Preset` default changes. Wire: none. diff --git a/quest/m1/obs-moq-video/rate-control.md b/quest/m1/obs-moq-video/rate-control.md index 81c8aea0ab..c450ba07a0 100644 --- a/quest/m1/obs-moq-video/rate-control.md +++ b/quest/m1/obs-moq-video/rate-control.md @@ -12,8 +12,9 @@ configured rate. ## Plan -Uses the reservation surface from moq-c (`moq_session_bandwidth`, -`moq_bandwidth_reserve`, `moq_reservation_grant`). Apply grants through +Uses the moq-ffi reservation surface through the generated C++ package +(`MoqSession::bandwidth`, `MoqBandwidth::reserve`, `MoqReservation::grant`), +not the hand-written moq-c, since the plugin moves off it first. Apply grants through the shape `moq_mux::rate::Control` uses (drops at once, raises ramp, hysteresis) rather than pushing every change into `obs_encoder_update`; whether that policy sits in moq-ffi behind the reservation or in the plugin depends on @@ -24,3 +25,7 @@ whether a second binding wants it. Verify against a shaped uplink and with ## Required - [OBS migration](/quest/m1/cpp/obs.md) - the plugin is on the generated C++ first + +## Related + +- [Audio follows the grant](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the shared rate policy audio adopts; OBS audio still reserves only diff --git a/quest/m1/obs-moq-video/source.md b/quest/m1/obs-moq-video/source.md index 9f80757a0f..e0178523f9 100644 --- a/quest/m1/obs-moq-video/source.md +++ b/quest/m1/obs-moq-video/source.md @@ -1,18 +1,18 @@ -# [XL] Replace OBS source FFmpeg decoding with moq-video +# [XL] Replace OBS source FFmpeg video decoding with moq-video ## Goal -The MoQ source loads and plays supported video without directly linking FFmpeg libraries. Attempt macOS GPU delivery in the first implementation; automatically fall back to CPU delivery when native presentation is unavailable. Windows and Linux initially retain a portable CPU path. +The MoQ source loads and plays supported video without FFmpeg's video libraries (libswscale, and libavcodec/libavutil for video). Attempt macOS GPU delivery in the first implementation; automatically fall back to CPU delivery when native presentation is unavailable. Windows and Linux initially retain a portable CPU path. ## Plan - Replace `cpp/obs/src/moq-source.cpp` video decode and conversion with moq-video through the generated C++ package: the moq-ffi video consumer for the CPU path, including frame ownership and cancellation semantics. Support H.264, HEVC, and available AV1 decoding; report unsupported VP8/VP9 explicitly until their follow-up lands. -- Decode with `MoqVideoDecoderOutput.native` set: each `MoqVideoDecodedFrame` retains the decoder's surface, `native()` borrows it (`PixelBuffer` on macOS) for as long as the frame lives, and `pixels(format)` is the CPU fallback. Hold the frame until OBS's GPU work reading it completes; held frames hold decoder pool slots. +- Decode with `MoqVideoDecoderOutput.native` set: each `MoqVideoDecodedFrame` retains the decoder's surface, `native()` borrows it (`PixelBuffer` on macOS) for as long as the frame lives, and `pixels(format)` is the CPU fallback. Hold the frame until OBS's GPU work reading it completes; held frames hold decoder pool slots. This surface landed on `dev` (#4094). - Implement the macOS presentation probe immediately: retain VideoToolbox PixelBuffer/IOSurface storage, inspect OBS graphics import and rendering support, and convert to OBS's expected color format on the GPU if necessary. Adapt the source render path to import textures on the graphics thread; preserve source timing instead of simply drawing the newest frame. An asynchronous CPU source API alone does not prove native GPU delivery. - Bound decoded frames retained by the render thread. On import failure, switch to the existing I420 delivery path and show the reason in Stats. Keep fallback stable for the stream/device configuration rather than retrying every frame; re-probe on a relevant configuration change or restart. Device loss and resize must retire old surfaces only after rendering completes. - Preserve timestamps, range/primaries, stride and plane layout, catalog/rendition changes, reconnect, visibility/deactivation behavior, and existing source settings. Carry frame-generation identity so late callbacks cannot display frames from a replaced source. -- Remove FFmpeg includes, CMake discovery/linkage, unit stubs, compile recipe requirements, and unused swresample linkage. Update OBS build/install docs and `doc/lib/cpp` together. libobs/Qt and native OS/GPU dependencies remain. -- Validate new code with decoded pixels and moving timestamps, GPU copy/readback traces, and p50/p95 decode-to-presentation delay. Exercise CPU fallback, unsupported codec, GPU import failure, device loss, repeated start/stop, rendition change, and delayed terminal completion. Verify no AVCodec/AVUtil/SWScale/SWResample imports using platform binary inspection. Load the artifact against the oldest supported OBS release and current stable release, using the repo's supported version policy at implementation time. +- Remove the video FFmpeg includes and swscale linkage. Audio still decodes through libavcodec until the audio playback quest replaces it, so whichever of the two lands second removes the remaining FFmpeg includes, CMake discovery/linkage, unit stubs, compile recipe requirements, and the unused swresample linkage. Update OBS build/install docs and `doc/lib/cpp` together. libobs/Qt and native OS/GPU dependencies remain. +- Validate new code with decoded pixels and moving timestamps, GPU copy/readback traces, and p50/p95 decode-to-presentation delay. Exercise CPU fallback, unsupported codec, GPU import failure, device loss, repeated start/stop, rendition change, and delayed terminal completion. Verify no SWScale imports (and no AVCodec/AVUtil/SWResample imports once audio playback has landed) using platform binary inspection. Load the artifact against the oldest supported OBS release and current stable release, using the repo's supported version policy at implementation time. ## Required @@ -21,3 +21,4 @@ The MoQ source loads and plays supported video without directly linking FFmpeg l ## Related - [VP8/VP9 decoding](/quest/m1/obs-moq-video/vpx.md) - restores deferred codec coverage independently +- [Audio playback](/quest/m1/obs-moq-video/audio-playback.md) - removes the audio half of the FFmpeg linkage diff --git a/quest/m1/obs-moq-video/windows.md b/quest/m1/obs-moq-video/windows.md index 787a739efd..886b0b6f4e 100644 --- a/quest/m1/obs-moq-video/windows.md +++ b/quest/m1/obs-moq-video/windows.md @@ -9,6 +9,13 @@ The moq-video OBS encoder consumes compositor output through D3D11 without CPU s - Inspect OBS `encoder_texture` shared handles and `encode_texture2` lock_key/next_key semantics. Record whether the source is packed RGB or split NV12 planes, its adapter LUID, and the lifetime OBS guarantees after the callback returns. - Reuse the native D3D11 surface where the encoder accepts the same device and format. Otherwise GPU-convert/blit into a bounded encoder-owned NV12 pool before returning the OBS synchronization key. An AddRef alone does not prevent OBS from overwriting pooled pixels. - Audit keyed mutex/fence sequencing, asynchronous completion, incompatible adapters, software fallback, resolution changes, and device removal. Start with Media Foundation, which accepts D3D11 surfaces. The current NVENC backend is Linux-only and directly imports CUDA surfaces there; Windows NVENC/D3D11 support is separate backend work. Do not infer interoperability from both APIs accepting a texture handle. +- Measure and map the encoder presets on Media Foundation while on the + hardware. Today `applied()` reports "AVLowLatencyMode requested, + unconfirmed" for every preset, because a refused knob is only logged and + the MFT is configured on the first frame. Make low-latency mode + confirmable (read it back, or fail when it is refused) so `Applied::preset` + can name a preset, and give Balanced and Quality distinct controls where + the MFT has them. - Trace readback and GPU copy counts on real Windows hardware. Verify pixels and A/V timestamps with a subscriber, compare latency and utilization to the CPU baseline, and exercise cancellation while textures remain in flight. Include hybrid-GPU and device-mismatch rejection where available. ## Required diff --git a/quest/m1/one-port/README.md b/quest/m1/one-port/README.md index a11067b4cb..8e9fbe8e00 100644 --- a/quest/m1/one-port/README.md +++ b/quest/m1/one-port/README.md @@ -71,7 +71,7 @@ this. The acceptor yields classified connections; `moq-rtmp`'s `axum_server::Server::from_listener` takes a listener that a channel of pre-accepted streams can stand behind. -## Quests +## Required - [UDP demux](/quest/m1/one-port/udp-demux.md) - one socket carries QUIC, STUN answers, and the WebRTC media path, with greasing off - [TCP acceptor](/quest/m1/one-port/tcp-demux.md) - one listener carries TLS-terminated HTTP, RTMP, and RTMPS diff --git a/quest/m1/open-gop-leading-pictures.md b/quest/m1/open-gop-leading-pictures.md index 8c53a747a8..c1f84922ea 100644 --- a/quest/m1/open-gop-leading-pictures.md +++ b/quest/m1/open-gop-leading-pictures.md @@ -25,16 +25,23 @@ everyone. sample of a group to `keyframe`, and `js/watch/src/video/decoder.ts` submits it as `"key"`): for the first group after any non-continuous transition, skip delta frames stamped before that group's keyframe. That covers a - subscribe, a declared discontinuity, and a latency skip: `#checkLatency` + subscribe, a declared discontinuity, and a latency skip: `#checkMaxAge` records the skip through `#gap` and `next()` reports the next frame with `continuous: false`. Latency skip also bumps playhead generation (startup delay) but does not flush the decoder. Leading pictures after that non-continuous transition are still skipped, as above; a viewer that skipped into a later GOP lacks its references just like a cold join. Every continuous group is passed through untouched. -- The same rule in the Rust decode path (`moq-video` decode consumers), with - an equivalent non-continuous signal from `container::Consumer`, so native - playback and the transcoder tune in the same way. +- The same rule in the Rust decode path (`moq-video` decode consumers), so + native playback and the transcoder tune in the same way. +- This quest owns the Rust non-continuous signal, which audio warmup and + consumer warmup reuse rather than each adding one. Today + `moq_mux::container::Consumer::poll_read` returns a bare frame, and + `discontinuity()` is a counter bumped on a declared marker group, an + unproven delivered hole, or a latency skip, but not on the subscribe itself. + Add the equivalent of JS `continuous`: false on the first frame after the + subscribe and after every bump, true otherwise. It changes the moq-mux + consumer API, so pick main or dev by whether the shape is additive. - Tests: a synthetic group with a keyframe followed by two earlier-stamped deltas is trimmed on the first group and kept on the second; and a viewer that plays continuously, then latency-skips into a later open GOP, has that @@ -48,3 +55,4 @@ everyone. ## Related - [Consumer warmup](/quest/m2/intra-refresh/consumer-warmup.md) - the `recovery_frame_cnt > 0` case this rule does not cover +- [Audio warmup](/quest/m1/audio-warmup.md) - keys its Opus pre-roll trim on the same signal diff --git a/quest/m1/opus-catalog-rate.md b/quest/m1/opus-catalog-rate.md new file mode 100644 index 0000000000..d6ba6bdd84 --- /dev/null +++ b/quest/m1/opus-catalog-rate.md @@ -0,0 +1,15 @@ +# [XS] Opus catalog rate matches the decoder + +## Goal + +MKV Opus import publishes the codec rate (48 kHz) as the catalog sample rate, +not the OpusHead input rate, which is informational. The catalog describes +what the decoder outputs. + +## Plan + +fMP4 import already does this. Check the other importers that build the +catalog from an OpusHead (FLV and the raw Opus import look similar) and align +them in the same PR. The OpusHead description keeps the input rate. + +Regression: a 44.1 kHz-input OpusHead imports with a 48 kHz catalog rate. diff --git a/quest/m1/opus-conceal.md b/quest/m1/opus-conceal.md index 507d45a24c..cac6f8255e 100644 --- a/quest/m1/opus-conceal.md +++ b/quest/m1/opus-conceal.md @@ -11,6 +11,10 @@ last real one, instead of 120 ms. The API is unchanged and lands on main. 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. +- The audio-codecs line branch moves this code to + `rs/moq-audio/src/decode/backend/libopus.rs` (same `max_frame_size`); + land the fix wherever the code lives when this starts, and port it on the + line's next merge from main otherwise. - Refuse loss before any packet has decoded, rather than surfacing libopus's `BUFFER_TOO_SMALL`. - Document on `decode` how much an empty packet conceals. diff --git a/quest/m1/origin-mount.md b/quest/m1/origin-mount.md deleted file mode 100644 index efc512d6b8..0000000000 --- a/quest/m1/origin-mount.md +++ /dev/null @@ -1,71 +0,0 @@ -# [M] A read-only origin mount - -## Goal - -A session's subscribe-side origin can show a subtree that lives outside its -root under a path inside it. The relay's authorizer grants the mount; neither a -token nor an auth server response can. moq.pro uses it to show a project's stats feed, served fleet-wide -at `.dash//stats`, as `/.pro/stats` inside a customer's own session, -so one `.dash` prefix route serves every project and no per-project route is -advertised. - -Read-only: nothing is published through a mount, and a mount never widens -what the session can publish. - -## Plan - -- `origin::Consumer::mount(at, source)` returns a consumer that behaves as - `self` everywhere except under `at`, where it resolves through `source`. - `request_broadcast` translates the path into `source`'s root, and - `announced()` merges both cursors, rewriting `source`'s paths under `at`. - The mount wins over anything `self` has under `at`: the embedder chose to - put it there. -- Everything that narrows or watches the base consumer reaches the sources - too. `Consumer::excluding(peer)`, applied by the lite and IETF publishers - after the relay hands over the mounted consumer, must exclude that peer - from every source, or a route learned through the client is advertised back - to it (split horizon). `routed_broadcast`'s retry watch must be installed - on the source's table at the translated path, not on the base origin, or a - request a mounted handler rejected never retries when the source's routes - change. -- Mounts are prefix-based and scoped like any handle: `source` keeps its own - root and patterns, so mounting an exact broadcast path exposes nothing - beneath it. -- Mounts are not part of `moq_auth::Grant`: that struct is also the JSON an - HTTP auth server returns and JS mirrors, so a field there would let any - auth response grant a cross-root read or change the wire contract. The - relay's admission path carries them instead, as equatable path pairs - (`at`, source path) that `Cluster::subscriber` resolves against its own - origin, so `moq-auth` never depends on `moq-net`. -- Stats attribute a mounted read to its logical path. Today - `Consumer::request_broadcast` derives the egress scope from its own - `root.join(path)`, so delegating untagged drops the traffic from session - counters, and tagging the source charges the source path. The mount layer - tags resolved broadcasts and announcements with the path under `at`. -- A mounted path is reachable only if the session's subscribe patterns cover - `at`. The embedder decides whether to add the mount; the patterns still - gate it, so the two agree. -- Lease re-checks compare mounts like root: a changed mount set closes the - session, same as a changed root today. -- This is additive and lands on `main`. It does not reopen the overlay - rejected in `/quest/m1/wildcard/README.md`: that overlay routed publishes - across roots. A mount is subscribe-only and has one source per path, so it - involves no route selection, splicing, or content identity. - -Tests: announce through a mount, subscribe through a mount to an unannounced -path under a dynamic prefix in `source`, a mount shadowing a local path, -patterns that exclude `at`, publishing under `at` unchanged by the mount -(denied stays denied, allowed stays allowed), a -split-horizon regression (a route learned from the client is not advertised -back through the mount), and a retry regression (a mounted handler rejects, -then a source route change makes the request resolve), and a stats -regression asserting mounted egress lands on the logical path. - -Benchmark: sweep mount count and subscriber session count together, so -cursor registration and announcement delivery touch only the mounts whose -source changed rather than scanning every mount or session. - -## Related - -- [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/p2p/README.md b/quest/m1/p2p/README.md index ab6d3029f7..b0817e2379 100644 --- a/quest/m1/p2p/README.md +++ b/quest/m1/p2p/README.md @@ -95,8 +95,8 @@ the same independence without fighting the browser. Rust already does the serving-side work: `best_route` re-runs on every table change and a live subscription re-splices onto a cheaper route with the same first hop at a group boundary, while an anonymous chain never wins. The JS -origin re-selects on provider change but ranks newest-first; that is -[route cost in the JS origin](/quest/m1/route-cost.md). The JS handshake +origin ranks the same way (`compareRoutes` in `js/net/src/origin.ts`: cost, +then fewest hops, then newest). The JS handshake already declares a random hop id, so browser hops are identified; the roster id is that hop id, held once per origin rather than once per session. @@ -110,16 +110,8 @@ relay's route ties the relay and loses on chain length. [Cost across scopes](/quest/m1/p2p/cost-scopes.md) writes the rule before the watcher depends on it. -### Fallback: a Rust + WASM in-tab hop - -If JS transit or ranking proves hard, the browser side can be moq-net in -WASM: `moq-wasm` already runs it over `web-transport-wasm`. A WASM hop would -hold the relay and peer sessions in Rust, giving one implementation of -signaling, policy, ranking, transit, and migration, and TS apps would connect -to it over an in-memory transport. It costs a web-sys data channel poll -transport, an in-memory bridge into `@moq/net`, and parsing every frame twice -in the tab. Recorded here so that decision is made with numbers, not -re-derived. +No Rust + WASM in-tab hop: [rs2ts](/quest/m1/rs2ts/remove-wasm.md) removes the +WASM build, so the browser side stays TypeScript. ### Risks @@ -133,7 +125,7 @@ re-derived. - Symmetric NATs on both ends fail ICE without TURN. By design the relay keeps serving. -## Quests +## Required - [Data channel transport](/quest/m1/p2p/transport.md) - `@moq/p2p` speaks qmux over one ordered RTCDataChannel behind the WebTransport shape `@moq/net` consumes - [Signaling and policy](/quest/m1/p2p/signal.md) - opted-in peers find each other under the prefix, the application picks who to dial, and the roster-size gate decides whether STUN is used @@ -148,7 +140,6 @@ re-derived. ## Related - [Peer grants](/quest/m1/auth/peer-grant.md) - the hop-bound credential a direct session presents; HMAC keys issue none -- [Route cost in the JS origin](/quest/m1/route-cost.md) - the watcher-side route pick this line needs - [One port](/quest/m1/one-port/README.md) - the relay answers STUN on its QUIC port - [E2EE](/quest/m1/e2ee/README.md) - what a peer would need if the token scope stopped being the trust boundary - [qmux on the QUIC core](/quest/m1/quic/qmux.md) - the stream core the unordered follow-up rides diff --git a/quest/m1/p2p/cost-scopes.md b/quest/m1/p2p/cost-scopes.md index 41f31fbd2b..1aecaa1c19 100644 --- a/quest/m1/p2p/cost-scopes.md +++ b/quest/m1/p2p/cost-scopes.md @@ -49,5 +49,4 @@ implementation quest it needs. ## Related -- [Route cost in the JS origin](/quest/m1/route-cost.md) - the ranking that consumes the rule -- [PoP skipping](/quest/m1/pop-skipping/README.md) - the mesh-side use of warm versus cold +- [Cluster routing](/quest/m1/cluster-routing.md) - the mesh-side use of cost diff --git a/quest/m1/p2p/transit.md b/quest/m1/p2p/transit.md index 9f54cff6aa..20a8f23d9d 100644 --- a/quest/m1/p2p/transit.md +++ b/quest/m1/p2p/transit.md @@ -32,10 +32,6 @@ the id appended and never on A; a chain already containing the id is dropped; a retraction on A retracts on B; two watchers of one tab share one upstream subscription. -## Required - -- [Route cost in the JS origin](/quest/m1/route-cost.md) - cost and hops must be carried on the entry before they can be forwarded - ## Related - [Watch opts in](/quest/m1/p2p/watch.md) - the first topology that needs a forwarding tab diff --git a/quest/m1/p2p/watch.md b/quest/m1/p2p/watch.md index 45bd5aa23b..476879b88d 100644 --- a/quest/m1/p2p/watch.md +++ b/quest/m1/p2p/watch.md @@ -12,7 +12,7 @@ goes away, with no visible interruption. `hang-watch` and `hang-publish` gain a `p2p` attribute that constructs `Peers` on the shared connection's origin with the demo's ICE servers and a `max` from the page; `demo/web` exposes the toggle. The route pick is the -origin's, from [cost ranking](/quest/m1/route-cost.md) under the rule from +origin's, from its cost ranking (`compareRoutes` in `js/net/src/origin.ts`) under the rule from [cost across scopes](/quest/m1/p2p/cost-scopes.md): a peer already carrying the broadcast wins, and its retraction falls back to the relay. @@ -26,5 +26,4 @@ watcher tab re-serving to another watcher through - [Signaling and policy](/quest/m1/p2p/signal.md) - [moq-cli joins](/quest/m1/p2p/cli.md) - the native hop the second topology shows - [Transit in the JS origin](/quest/m1/p2p/transit.md) -- [Route cost in the JS origin](/quest/m1/route-cost.md) - [Cost across scopes](/quest/m1/p2p/cost-scopes.md) diff --git a/quest/m1/path-patterns.md b/quest/m1/path-patterns.md index fff74556a5..90b43fb304 100644 --- a/quest/m1/path-patterns.md +++ b/quest/m1/path-patterns.md @@ -98,5 +98,5 @@ matches, containment refusal, and old-version behavior. ## Related -- [Wildcard advertisements](/quest/m1/wildcard/README.md) - routing adopts the +- [Wildcard advertisements](/quest/m0/wildcard/README.md) - routing adopts the matcher while retaining its own cost, pool, refusal, and resolution work diff --git a/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md b/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md index a4883e683e..a5fccaa027 100644 --- a/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md +++ b/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md @@ -21,7 +21,10 @@ the tokio worker path: That is `clock_gettime`. Roughly 2.5% of relay CPU spent reading the clock. The profile is the since-deleted quiche driver's; re-measure on noq before -and after. +and after. The closed, unmerged prototype +[#3136](https://github.com/moq-dev/moq/pull/3136) froze the clock per turn +behind an RAII guard on that driver; its shape and tests are a starting +point. Where the reads are: diff --git a/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md b/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md index d0f0e32311..5315677c57 100644 --- a/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md +++ b/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md @@ -8,7 +8,13 @@ beats `SendMsg` end to end before it is on by default. ## Plan -Follow-up to #2875. +Follow-up to #2875. The closed, unmerged prototype +[#3224](https://github.com/moq-dev/moq/pull/3224) (on the quiche-era dev +tree) did this together with #3204's fixed buffers, opt-in behind +`udp::Config::send_zc_threshold`. On loopback it was 6 to 9% slower, but +`IORING_SEND_ZC_REPORT_USAGE` showed the kernel copying there, so loopback +measures only the forced-copy overhead: the sweep needs a remote peer +through a physical NIC. The UDP path already assembles up to 64 KiB GSO trains in stable pool buffers, then submits `SendMsg` and recycles the buffer at the first CQE. Large trains are the promising case for `SENDMSG_ZC`; individual QUIC datagrams are likely below the copy-avoidance crossover. diff --git a/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md b/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md index 72f7cc1a8a..86f4ebb14a 100644 --- a/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md +++ b/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md @@ -9,6 +9,15 @@ measurable gain over plain `SendMsgZc` or the change is dropped. ## Plan Follow-up to #2875 and dependent on the `SENDMSG_ZC` experiment in #3201. +The closed, unmerged prototype [#3224](https://github.com/moq-dev/moq/pull/3224) +built this; reuse its lease and quarantine handling. + +Fixed buffers on `SENDMSG_ZC` need Linux 6.15 (vectored registered-buffer +support), above moq-uring's 6.12 floor, so the fixed path must fall back: +#3224 retried with ordinary `SENDMSG` on `EINVAL` or `EOPNOTSUPP` and +disabled later attempts on that ring. #3224 also filled the SQE `buf_index` +with a raw-SQE adapter because the `io-uring` crate did not expose it for +this opcode; check the current crate first. The TX pool already owns stable `Box<[u8]>` allocations and grows lazily. If zero-copy send wins, registering those allocations lets send SQEs reference fixed buffers and can reduce repeated page accounting on the large-train path. diff --git a/quest/m1/perf/README.md b/quest/m1/perf/README.md index b0b17f4d6f..95ae136cdd 100644 --- a/quest/m1/perf/README.md +++ b/quest/m1/perf/README.md @@ -3,7 +3,7 @@ ## Goal Reduce relay CPU per session, raise the per-worker throughput ceiling, and -hold tail latency on the dev thread-per-core stack by eliminating measured +hold tail latency on the thread-per-core stack by eliminating measured hot-path costs: redundant copies, locks, atomics, clock reads, allocations, and syscalls. Not io_uring specific: anything on the relay's hot path qualifies, including the shared moq-net model layer and kio. @@ -14,6 +14,10 @@ outcome that abandons the quest. ## Plan +Quests branch from main unless they say otherwise; +[Run to quiescence](/quest/m1/perf/uring-quiescence.md) needs dev, where +`kio`'s `Tasks::poll` changed (#4156). + Planning quests can settle their contracts independently. Facts from the 2026-09 hot-path survey, so quests don't re-litigate them: @@ -39,7 +43,7 @@ The relay's `/metrics` endpoint already carries the ring-level counters (enters, park/wake, batch effectiveness) several quests want as evidence, one row per io_uring worker. -## Quests +## Required - [Open contract](/quest/m1/perf/uring-open-contract.md) - plan concurrent WebTransport opening and cancellation diff --git a/quest/m1/perf/egress-keepalive.md b/quest/m1/perf/egress-keepalive.md index d07b00d9b3..208b9efa9c 100644 --- a/quest/m1/perf/egress-keepalive.md +++ b/quest/m1/perf/egress-keepalive.md @@ -23,7 +23,7 @@ before optimizing the remaining publisher overhead. Extend the existing group and track Criterion targets and add a bounded session regression to normal CI. Keep -`slow_batch_reader_survives_expiry_with_keep_alive`, and cover expiry scans +`slow_prefetch_reader_survives_expiry` (`rs/moq-net/src/model/track.rs`), and cover expiry scans while a batch drains, cancellation, and eventual expiry after reads stop. Report delivered bytes, refresh cost, CPU, and throughput for paired runs; fewer refresh calls alone are not evidence of a win. diff --git a/quest/m1/perf/egress-requeue.md b/quest/m1/perf/egress-requeue.md index ed9ae1d090..90ba398717 100644 --- a/quest/m1/perf/egress-requeue.md +++ b/quest/m1/perf/egress-requeue.md @@ -29,7 +29,3 @@ Linux. Latency must not regress at the chosen budget. A no-win keeps 1. The [quiescence quest](/quest/m1/perf/uring-quiescence.md) sweeps this budget together with its pass budget; land whichever runs first and fold the other's sweep in. - -## Closes - -- [#3120](https://github.com/moq-dev/moq/issues/3120) - close this issue when the quest finishes diff --git a/quest/m1/perf/uring-one-enter.md b/quest/m1/perf/uring-one-enter.md index 4cd8e3f239..5ff2baf3de 100644 --- a/quest/m1/perf/uring-one-enter.md +++ b/quest/m1/perf/uring-one-enter.md @@ -10,7 +10,7 @@ a ratio of two totals. ## Plan -Branch from dev. The loop in `Worker::block_on` +Branch from main. The loop in `Worker::block_on` (rs/moq-uring/src/worker.rs:164-187) runs one task pass, then `pump` (`submit()` at worker.rs:250, then reap and dispatch), then `maybe_park`, whose enter (`submit_and_wait(1)` or a timed `enter(to_submit, 1, diff --git a/quest/m1/perf/uring-quiescence.md b/quest/m1/perf/uring-quiescence.md index 529aae15c7..88b4879722 100644 --- a/quest/m1/perf/uring-quiescence.md +++ b/quest/m1/perf/uring-quiescence.md @@ -15,7 +15,8 @@ exhaustion, or to a quantum, before touching the ring. ## Plan -Branch from dev. Keep the fairness the one-pass rule protects: a forward wake +Branch from dev: `kio`'s `Tasks::poll` changed only there (#4156 merged +`Pollable` into `Task`), and the pass budget builds on that version. Keep the fairness the one-pass rule protects: a forward wake chain must not starve the caller's other arms, and one connection's backlog must not starve the socket. diff --git a/quest/m1/performance-comparisons.md b/quest/m1/performance-comparisons.md index e9fcaf8b3a..ab8a640e72 100644 --- a/quest/m1/performance-comparisons.md +++ b/quest/m1/performance-comparisons.md @@ -38,7 +38,7 @@ while extending this harness rather than creating another benchmark runner. ## Required -- [Thin justfiles](/quest/m1/tooling/justfiles.md) - finish benchmark script relocation before changing its lifecycle +- [Tooling](/quest/m1/tooling/README.md) - the recipe and script layout `just bench` runs under ## Related diff --git a/quest/m1/performance-profiles.md b/quest/m1/performance-profiles.md index eb52ef23b1..e0c5da304c 100644 --- a/quest/m1/performance-profiles.md +++ b/quest/m1/performance-profiles.md @@ -11,7 +11,7 @@ cost. Profiling is opt-in and has no production overhead when disabled. `bench/run.sh` already owns the builds, relay PID, workload, and host samples, but has no profiler integration. Reuse that lifecycle instead of adding a second launcher. `Cargo.toml` already has a `profiling` profile and -`rs/moq-native/src/jemalloc.rs` already supports on-demand heap dumps. +`rs/moq-tokio/src/jemalloc.rs` already supports on-demand heap dumps. - Add a focused `just` recipe selecting workload, duration, and capture mode through one configuration. Reuse locked builds and the existing profiling Cargo profile; @@ -46,3 +46,4 @@ verify current supported versions and pin any newly installed tools. - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - repeatable results and artifact metadata - [Relay memory](/quest/m1/relay-memory.md) - retained route and announcement memory +- [Release profile](/quest/m1/release-profile.md) - also changes `[profile.profiling]`; land one, then rebase the other diff --git a/quest/m1/pipewire-dup-cameras.md b/quest/m1/pipewire-dup-cameras.md new file mode 100644 index 0000000000..c9ad2a46fc --- /dev/null +++ b/quest/m1/pipewire-dup-cameras.md @@ -0,0 +1,24 @@ +# [XS] A webcam lists once with PipeWire enabled + +## Goal + +`moq_video::capture::cameras()` on Linux with the `pipewire` feature lists a +webcam once. Today a UVC webcam appears as its V4L2 device and again as the +PipeWire node that wraps it ([#4022](https://github.com/moq-dev/moq/pull/4022)). + +## Plan + +Decided: hide PipeWire camera nodes with `device.api = v4l2` whose device the +V4L2 backend already listed, keeping the shorter list over exposing the +backend choice. Nodes V4L2 cannot see stay: libcamera cameras (a Raspberry Pi +CSI camera) and everything inside a sandbox, where V4L2 lists nothing. + +Guidance: + +- Match on the node's V4L2 device path property (`api.v4l2.path`), not the + description, so two identical webcams stay distinct. +- Only the listing changes. `pipewire:` still opens a hidden node, and + the `pipewire` default keeps resolving by priority. +- Update the `cameras()` doc that currently says a webcam appears once per + backend, and cover the filter with a unit test over scanned node + properties. diff --git a/quest/m1/pop-skipping/README.md b/quest/m1/pop-skipping/README.md deleted file mode 100644 index c1399c0b19..0000000000 --- a/quest/m1/pop-skipping/README.md +++ /dev/null @@ -1,158 +0,0 @@ -# Cache-aware PoP skipping - -## Goal - -Give an unpopular broadcast a short cold path without sacrificing the backhaul -deduplication a sparse mesh gets once the broadcast is warm. Every relay connects -to all healthy relays in its own PoP, its base-graph neighbor PoPs, and the PoPs at -graph distance two. A cold subscriber uses the direct distance-two session rather -than forwarding through an idle intermediate; a relay with any warm track advertises -the cheaper route, pulling later subscribers back onto the copy the cluster already -has. Full eligible-PoP pairing is accepted for now; on the topology this was -designed against it takes live persistent relay sessions from 29 to 78 (of 120 for -a full mesh). - -For `sjc0 -> dal0 -> iad0`, with the publisher in IAD: - -1. Cold SJC sees direct `sjc0 -> iad0` at 5 and `sjc0 -> dal0 -> iad0` at 6, so it - takes the direct session on price rather than on a tie-break. -2. Another SJC relay reaches that warm copy over the same-PoP link for 1, against 5 - to open its own. -3. A DAL subscriber initially pulls directly from IAD at cost 3. DAL then ranks - ahead of SJC because its cold route is cheaper (3 against 5), so SJC migrates at - a group boundary and both share DAL's one IAD pull. -4. SEA makes the same local decision and can join the already-warm aggregation - tree rather than opening another copy from IAD. - -## Plan - -### Where this stands after prefix routes - -[moq#3225](https://github.com/moq-dev/moq/pull/3225) made an announcement a -route over a path *prefix* and deleted the machinery this questline had -already landed: the warm-cost discount, `COST_LINGER`, the -`(cold, hash)` adoption gate, the handover hold, and the per-broadcast front -that hosted all of it. A relay now forwards accumulated costs only. - -What survives is the part that was expensive to get right: - -- `origin::Cost { warm, cold }` on the wire, decoded on lite-06, with - `Cost::UNKNOWN` reading an inexpressible cold as the ceiling rather than as - free. -- `route_order`, which still breaks a warm tie on the lower cold cost ahead of - hop count. -- `DRAIN_COST`, and the hop list as the loop check. -- The link-price decisions below, which were rulings about the topology rather - than about the code that read them. - -So this questline is no longer "add a rank to a working discount". It is -re-deriving warmth on a route model that prices prefixes, then putting the -adoption gate back on top. - -### The tension prefix routes introduce - -Warmth is a property of one broadcast. A route covers a prefix, which is a -claim about a set of paths. A relay carrying `pid/foo.hang` knows nothing about -the rest of `pid/`, so there is no honest way to discount the prefix route it -already advertises: doing so would attract subscribers for every cold path -underneath it. - -### Decisions - -- **A carrying relay advertises the exact broadcast path as its own route, - priced warm.** Per-broadcast warmth becomes a more specific route rather than - a discount on a broader one. Nothing new goes on the wire, and the existing - selection rule already prefers it. -- **Selection keeps specificity ahead of cost.** `best_server` filters to the - longest covering prefix and only then orders by `route_order`, and the lite - draft says the same. This matches longest-prefix-match everywhere it appears - (IP forwarding, BGP, DNS closest encloser, URL routers), and for the same - reason: routes of different prefix length describe different destination - sets, so comparing their costs asks "what does this broadcast cost" against - "what would anything under here cost". Cost decides between routes that cover - the same thing, which is exactly where the warm/cold pair was designed to - work. -- **A draining carrier retracts its exact-path route rather than repricing - it.** Under specificity-first, forgoing the discount is not enough: a route - priced at the ceiling still wins on specificity and keeps attracting - subscribers a drain is trying to move. Retraction drops the claim, and the - broader route the content is still reachable through takes over. This - replaces the draft's ceiling-exemption paragraph, which was written when the - discount rode a single per-broadcast advertisement. -- **Adoption and resume need different identities.** Adoption keys on the - *last* hop of a route, the peer that advertised it and the parent a relay - would be adopting; resuming a subscription keys on the *first* hop, who - produced the content, which alternate routes to one publisher deliberately - share. The pre-#3225 front kept both (`FrontState.publisher` for the first, - `handover_allowed` and the hold for the last), with `same_identity` comparing - either and refusing `Hop::UNKNOWN` on both. [moq#3312](https://github.com/moq-dev/moq/pull/3312) restored that comparison rule and the - publisher half as the first-hop resume; [Rank](/quest/m1/pop-skipping/rank.md) owns - the carrier half rather than reusing the wrong one. -- The operator hand-authors one undirected base graph. Same-PoP connectivity is - unconditional, not a self-edge operators must remember. The radius-two closure - and shortest base-graph distance are derived and validated. -- The initial reference link costs are local 1, base neighbor 3, and distance-two - skip 5. The invariant that buys PoP skipping is `skip < 2 * neighbor`: a cold - skip must beat the equivalent two-edge path outright, so 5 against 6 works while - anything from 6 up preserves the old cold path and defeats this quest. Keeping - the skip strictly below rather than equal to the two-edge path means the choice - is made on price, not on the hop-count tie-break below it. -- No link is free, including a same-PoP one. A local transfer still costs a NIC, a - hop of latency, and a copy; what is genuinely free is bytes already flowing, and - that is the warm route's job, not the link price's. A floor of 1 also prices - chain *length*, so a PoP converges on a flat tree around one puller instead of - daisy-chaining at no cost, and it means adopting a parent strictly increases the - adopter's own cold cost, which leaves the per-broadcast hash as a tie-break - between equally-placed relays rather than the only thing keeping the order - strict. -- Warmth is broadcast-wide: demand for any track makes the broadcast warm. When - demand drains, the exact-path route follows the existing 30-second spliced-track - lifecycle (`TRACK_IDLE_LINGER`) while at least one track copy remains retained. - Do not add a second timer with a different definition; `COST_LINGER` was that - timer and is already gone. A retained track has canceled its upstream - subscription, so "warm" here intentionally means reusable route/track state plus - hysteresis, not that every future byte is already in memory. -- Provider economics are directional and dominate locality in a mixed-provider - deployment. Conceptually the metric is `(serving provider egress class, - topology distance)`: pulling from an unmetered relay can be cheaper than the - reverse direction, while equal provider classes retain the 1/3/5 locality - order. Keep these components structured until the final peer cost is encoded; - choose an encoding whose economic stride exceeds the maximum accumulated - topology distance allowed by the bounded hop list, which at a 32-entry chain - and a 5-cost worst link is 160. -- Cluster sessions use `moq-lite-06`, explicitly. Lite05 silently drops - the cost; the MoQT Cluster extension is not the chosen cluster wire for this - questline. -- Fleet rollout stays downstream: moq.pro owns rendering the priced radius-two - topology, the two-phase Lite06 cluster cutover, and the staging and live - route-verification proofs. This questline completes when the mechanism above - lands here. - -### Peer reconfiguration - -The URL-backed half is complete in -[moq#2874](https://github.com/moq-dev/moq/pull/2874): canonical identity stays -separate from dial configuration, so changing `?cost=` or an inline credential -replaces the active session while an identical render is a no-op. It also -preserves the last-good topology on malformed input, keeps an identical fallback -session alive, redacts credentials from parse errors, and tracks overlapping -gossip paths so an old unannounce cannot stale a replacement. The remaining -boundary is structured policy that does not live in the URL: the two directional -costs of one bidirectional session, which one `?cost=` cannot split. - -## Quests - -- [Warm advertise](/quest/m1/pop-skipping/warm-advertise.md) - a carrying relay - advertises the exact broadcast path as a warm route, and retracts it when - draining or idle -- [Rank](/quest/m1/pop-skipping/rank.md) - rank warm relay candidates by cold - cost and adopt only downhill, with a hold that outlasts cost propagation -- [Peer reconfigure](/quest/m1/pop-skipping/peer-reconfigure.md) - structured - peer entries carry the charged cost, the declared cost, and the credential, - and a change redials, proving both sides of an asymmetric link - -## Related - -- [drain](/quest/m1/drain/README.md) - a second relay per PoP makes the same-PoP link price and its connection cardinality operationally important -- [wildcard](/quest/m1/wildcard/README.md) - it reuses this questline's route cost, and needs a cluster on Lite06 -- [relay-memory](/quest/m1/relay-memory.md) - a denser mesh multiplies whatever a non-selected route costs diff --git a/quest/m1/pop-skipping/peer-reconfigure.md b/quest/m1/pop-skipping/peer-reconfigure.md deleted file mode 100644 index 21313053e4..0000000000 --- a/quest/m1/pop-skipping/peer-reconfigure.md +++ /dev/null @@ -1,70 +0,0 @@ -# [M] Peer reconfigure - -## Goal - -A cluster peer entry can carry structured policy that has no home in a URL: -the price this relay charges to pull from the peer, the price it declares to -the peer for the reverse direction, and the credential. Changing any of it at -runtime replaces the live session the same way a `?cost=` change does today, -and an identical render stays a no-op. - -## Plan - -The typed public configuration, URL/object normalization, credentials, and -symmetric policy are supplied by dev. This quest removes the explicit refusal -of asymmetric costs and wires their distinct meanings without another change -to the peer configuration type. Preserve the compatibility and validation -rules below rather than implementing a second parser. - -[moq#2874](https://github.com/moq-dev/moq/pull/2874) landed the URL half: -`?cost=` and an inline `?jwt=` are dial configuration, the query-less URL is -the identity, and a changed render replaces the session while preserving -inactive fallbacks and the last-good topology. What it cannot express is an -asymmetric link from one side. One `?cost=N` is both what this relay charges -locally to pull from the peer and what it declares in SETUP as its own egress -price, so pricing the two directions differently needs the peer to list us -with its own `?cost=`. - -Decisions: - -- Peer entries in `cluster.connect` and `connect_api` accept an object beside - the bare URL string, deserialized untagged into the existing `DialTarget`: - `url` (required, still the canonical identity), `cost` (what this relay - charges to pull from the peer, the routing input), `egress` (what it declares - in SETUP as its own price toward the peer, defaulting to `cost`), and - `token` (replaces an inline `?jwt=`). `DialTarget` grows `egress` and a - normalized credential, with an inline `?jwt=` and an object `token` parsed - to the same representation, and its equality covers every field, so an - `egress`-only or `token`-only update is a change and the token is never - dropped. Unknown fields reject the whole list, so a typo keeps the - last-good topology exactly as a malformed URL does, and so does an object - whose `url` still carries `?cost=` or `?jwt=`: policy has one home per - form, and a mixed entry is rejected rather than given a precedence a - migration could silently get wrong. -- Gossip and mDNS keep advertising URLs only. Their allowlist admits `?cost=` - alone, and the draft already makes a declared price an assertion the - receiver may override, so a peer never needs to push structured policy at us. -- Any field change replaces the session, the rule [moq#2874](https://github.com/moq-dev/moq/pull/2874) - set for `?cost=`. A URL entry and an object entry that normalize to the same - `DialTarget` are one entry, deduplicated as `parse_peer_list` already does; - two entries for one identity with differing policy are the conflict that - rejects the list, whatever their forms. Reconciling cost in place without a - redial is deliberately not done: it would be a second code path for one - field. -- No per-peer wire version. ALPN negotiation picks the best common version per - session and the global `--version` list is the only pin; a per-peer - override is a fleet cutover concern that stays downstream. - -The split lands in `moq_tokio::Client` as separate charged and declared costs -(today `with_cost` sets both), and in the relay's session setup so the routing -side reads the charged value while SETUP carries the declared one. - -Tests: the asymmetric link in both directions, two relays each pricing the -other differently, with routes ranked per side from the charged value and the -declared value visible on the far side; a `connect_api` update that changes -only `egress` or only `token` redials that peer and no other; an identical -object render is a no-op; equivalent URL and object forms of one peer -deduplicate while differing policies for one identity conflict; an unknown -field, or an object whose `url` carries `?cost=` or `?jwt=`, keeps the -previous list. Update `doc/bin/relay/cluster.md` and -`doc/bin/relay/config.md` with the object form. diff --git a/quest/m1/pop-skipping/rank.md b/quest/m1/pop-skipping/rank.md deleted file mode 100644 index 955bf6f0bc..0000000000 --- a/quest/m1/pop-skipping/rank.md +++ /dev/null @@ -1,91 +0,0 @@ -# [L] Rank - -## Goal - -A relay adopts another relay's warm copy only when that relay is strictly -closer to the publisher, so a PoP converges on one aggregation point instead of -a coin flip, and no two relays can adopt each other. - -## Plan - -Once [Warm advertise](/quest/m1/pop-skipping/warm-advertise.md) lands, two -relays carrying one broadcast both advertise it warm and tie: warm cost cannot -separate them, and only the deterministic hash would, so the aggregation root is -picked at random. In the `sjc0 -> dal0 -> iad0` topology that lets SJC (two -links from IAD) win over DAL (one link), and the cluster carries the extra -backhaul it was supposed to remove. - -Break that tie on cold cost, which is the relay's own distance to the publisher -with warm discounts removed and is already on the wire and already ranked below -warm in `route_order`. Adoption descends `(cold cost, hash(broadcast path, relay -hop id))`: lower cold wins, equal cold takes the lower hash, and a relay keeps -its own upstream rather than adopting a peer that ranks above it. Including the -broadcast path in the hash spreads ownership instead of making one relay win -every broadcast. A relay advertises its *own* rank, not that of a parent it -adopted, so every warm edge descends and cycles cannot form. - -Adopting a parent adds that link to the adopter's cold cost, so it can only rank -above its parent afterwards. That is what makes descent automatic, and it is why -no link may be priced free. - -### The hold, and why it is not optional - -Cold is a value each relay reports about itself, so a report still crossing the -mesh can be lower than what its sender would say now. Rings of relays can each -rank a stale neighbour below themselves and all let go at once, leaving the -broadcast with no source. Rising costs are the whole hazard; if costs only fell, -a stale value would only make a peer look worse than it is. A GOAWAY prices a -route at the ceiling while neighbours still remember it cheap, so the trigger is -a rolling restart, not an exotic race. - -Hold a re-parent onto another relay long enough for the costs it rests on to -land, and re-evaluate when the hold expires rather than committing to the -decision that armed it. The sizing rule is "longer than an announcement crosses -the mesh", plus a stable per-relay spread so a PoP does not reconsider on one -instant. The hold covers only trading a working upstream for a better one: -an idle relay is pulling nothing, a one-hop chain is the publisher itself, and -leaving a drained or vanished route stays immediate. - -### Two identities, not one - -Adoption is a statement about the adjacent relay, and that is a different -identity from the first-hop resume rule [moq#3312](https://github.com/moq-dev/moq/pull/3312) landed. -The pre-#3225 front kept both, because they answer different questions: - -- The **first** hop is who produced the content. Alternate routes to one - publisher deliberately share it, which is what makes them spliceable, and it - is what the resume rule keys on. -- The **last** hop is the peer that advertised the route: the parent a relay - would be adopting. `handover_allowed` and the hold both keyed on it, and the - rank hash was taken over it (`fnv_key(name, [peer])`). - -Use the last hop here. Keying adoption on the first hop would make every carrier -of one broadcast look like the same peer, so a changed parent would go -undetected and two relays could adopt each other, which is the failure the hold -exists to prevent. - -What this quest reuses from the resume rule is the comparison rather than the -field: `Hop::UNKNOWN` identifies nobody and never matches itself, so two -anonymous relays must not pass for one relay reconnecting and skip the gate. - -Update `drafts/draft-lcurley-moq-lite.md` in the same change, restoring the -adoption-rank rule that -[moq#3278](https://github.com/moq-dev/moq/pull/3278) removed. - -### Tests - -The asymmetric chain (DAL outranks SJC regardless of hash), the equal-cost race -(exactly the lower hash keeps its upstream while the other adopts it), a -three-node transitive tree, simultaneous updates, a ring of relays each holding -a stale cheaper report (which must not leave the broadcast sourceless), route -loss and reversion, and an unknown-cold peer losing to a known cheap one in both -hash directions. A live track must migrate only at a group boundary and must not -see announcement churn. - -Write every gate test over both hash directions, or it proves nothing beyond a -lucky hash. - -## Required - -- [Warm advertise](/quest/m1/pop-skipping/warm-advertise.md) - there is nothing - to rank until two relays can both advertise one broadcast as warm diff --git a/quest/m1/pop-skipping/warm-advertise.md b/quest/m1/pop-skipping/warm-advertise.md deleted file mode 100644 index 39a23daaef..0000000000 --- a/quest/m1/pop-skipping/warm-advertise.md +++ /dev/null @@ -1,51 +0,0 @@ -# [L] Warm advertise - -## Goal - -A relay carrying a broadcast advertises that broadcast's exact path as its own -route, priced warm, so later subscribers reach the copy the cluster already has -instead of opening another one. It retracts that route when the broadcast goes -idle or its own path starts draining. - -## Plan - -Warmth is per-broadcast and a route covers a prefix, so the discount cannot ride -the prefix route a relay already advertises: carrying `pid/foo.hang` says -nothing about the rest of `pid/`. Advertise the exact path instead, as a second, -more specific route. `best_server` already filters to the longest covering -prefix before ordering by cost, so the warm route wins for that one broadcast -and changes nothing for its neighbors. - -Price it the way the deleted discount did: warm zero (the ingress is already -paid for), cold forwarded accumulated, since cold prices the path this relay -would have to open if it were not already carrying. Two relays both carrying -then tie on warm and are separated by cold, which is what -[Rank](/quest/m1/pop-skipping/rank.md) builds on. - -Lifecycle follows the state that already exists rather than a new timer. -The route appears when a track under the broadcast has demand and stays while -any track retains its source copy inside `TRACK_IDLE_LINGER`; it is retracted -once the last copy expires. `COST_LINGER` was the parallel five-second timer -with a different definition and is already gone with -[moq#3225](https://github.com/moq-dev/moq/pull/3225); do not reintroduce it. - -Retract rather than reprice when the serving path drains. Under -specificity-first selection a ceiling-priced exact-path route still outranks -every broader route, so a drain that only repriced would keep attracting the -subscribers it is trying to move. Dropping the claim lets the broader route the -content is still reachable through take over. Update -`drafts/draft-lcurley-moq-lite.md` in the same change: this replaces the -ceiling-exemption paragraph that -[moq#3278](https://github.com/moq-dev/moq/pull/3278) removed, and it is a behavior change the -draft has to carry. - -Make both Lite and IETF publishers advertise from one warmth signal, so a -cluster selecting Lite06 and one on the Cluster extension mean the same thing by -warm. - -Tests: two tracks with staggered demand keep one warm route alive; demand -returning during retention does not churn the advertisement; the route is -retracted after the last copy expires and is not re-advertised afterwards; a -draining path retracts rather than repricing, and a subscriber on it moves to -the broader route; a second relay carrying the same broadcast ties on warm and -is separated by cold. diff --git a/quest/m1/processor/README.md b/quest/m1/processor/README.md index 93041435b6..041fc85997 100644 --- a/quest/m1/processor/README.md +++ b/quest/m1/processor/README.md @@ -4,7 +4,8 @@ A customer runs a worker in its own environment, connects outbound to a MoQ deployment, reads only eligible source media, and publishes an on-demand -contribution at `/.pro`. The platform supplies +contribution under the processor's own prefix, mirroring the source path +(for example `./`). The platform supplies registration, scoped credentials, routing, demand, status, and usage visibility; it does not upload or execute customer code. @@ -14,14 +15,23 @@ service contract, and credential minting) stay downstream in moq.pro. The contract is not vision-specific: captioning, moderation, telemetry extraction, and custom transforms use the same worker lifecycle. -## Quests +## Plan + +Decided: derived output follows [Wildcard](/quest/m0/wildcard/README.md)'s +service-prefix layout, not `/.pro`. Suffix routing is +dropped everywhere, and a prefix claim needs the variable part of the path +trailing, so the processor claims its prefix and mirrors the source path +beneath it. The source's catalog reaches the output through a +cross-broadcast reference ([media contract](/quest/m1/processor/media-contract.md)). + +## Required - [Processor media contract](/quest/m1/processor/media-contract.md) - define contribution references, source relations, and correlation in the Hang catalog - [Advertise-only authorization](/quest/m1/processor/advertise-auth.md) - a - worker may advertise its contribution suffix without receiving permission to - publish arbitrary matching paths + worker may advertise its service prefix without receiving permission to + publish arbitrary paths under it - [Expiring media grants](/quest/m1/processor/grant-lease.md) - enforce short-lived exact grants on already-open consumer and producer handles @@ -30,5 +40,6 @@ and custom transforms use the same worker lifecycle. - [Reference vision worker](/quest/m3/processor-vision.md) - a runnable worker publishes frame-correlated detections and proves demand, reconnect, failover, and teardown end to end -- [Wildcard advertisements](/quest/m1/wildcard/README.md) - lets a dormant - processor advertise what it could serve without enumerating live sources +- [Wildcard advertisements](/quest/m0/wildcard/README.md) - lets a dormant + processor advertise what it could serve without enumerating live sources, + and sets the service-prefix layout diff --git a/quest/m1/processor/advertise-auth.md b/quest/m1/processor/advertise-auth.md index 26c1d8faaa..1c8fc2bead 100644 --- a/quest/m1/processor/advertise-auth.md +++ b/quest/m1/processor/advertise-auth.md @@ -2,18 +2,24 @@ ## Goal -A v1 worker credential can advertise an allowed wildcard without receiving -permission to publish any path matching it. Relays enforce advertise and -publish as independent capabilities before external processor credentials are +A v1 worker credential can advertise an allowed prefix without receiving +permission to publish any path under it. Relays enforce advertise and publish +as independent capabilities before external processor credentials are minted. ## Plan -Add an explicit advertise pattern union to the v1 claims, token SDKs, origin -scope, and relay authorization model. Wildcard authorization checks that -scope rather than borrowing the publish union. A concrete announcement or -publish request still requires publish permission, so an advertise-only worker -cannot bypass the demand exchange. +Add an explicit advertise prefix scope to the v1 claims, token SDKs, origin +scope, and relay authorization model. [Wildcard](/quest/m0/wildcard/README.md) +checks advertisements against `moq_auth::Claims.publish` today; this quest +gives them their own scope instead of borrowing the publish one. A concrete +announcement or publish request still requires publish permission, so an +advertise-only worker cannot bypass the demand exchange. + +Decided: the advertise scope is prefix-only. Advertising is prefix-only on +every wire (Wildcard's decision) and suffix routing is dropped, so leading-star +and suffix advertise patterns have nothing to authorize. Token claim patterns +keep their suffix support for publish and subscribe. Preserve current customer credentials in the wire and authorization design: existing claims retain their current publish-implies-advertise behavior, while @@ -21,7 +27,11 @@ the new v1 claim separates the capabilities. Land the claims, SDK, origin-scope, relay authorization, and tests without combining the release or the moq.pro (downstream) pin rollout into this quest. -Cover containment, rebasing, leading-star and suffix patterns, missing versus -empty advertise scope, v0 compatibility, token revalidation, concrete announce, -publish, FETCH, and a wildcard demand that receives only an exact short-lived -publish grant. +Cover containment, rebasing, missing versus empty advertise scope, v0 +compatibility, token revalidation, concrete announce, publish, FETCH, and a +prefix demand that receives only an exact short-lived publish grant. + +## Required + +- [Wildcard](/quest/m0/wildcard/README.md) - the prefix advertisements this scopes +- [Auth](/quest/m1/auth/README.md) - the v1 claims and relay authorization this extends diff --git a/quest/m1/processor/grant-lease.md b/quest/m1/processor/grant-lease.md index d58b3b226d..66ee0bf16a 100644 --- a/quest/m1/processor/grant-lease.md +++ b/quest/m1/processor/grant-lease.md @@ -18,9 +18,15 @@ missing, late, broader, or mismatched refresh closes them. Reconnecting with an expired grant is denied as it is today. Keep deadline enforcement in the relay authorization owner rather than a -cooperative worker timer. Cover an idle open handle, active source reads, -active publication, refresh before expiry, refresh after demand ends, relay -clock skew within the token policy, disconnect races, HTTP and HLS rejection of +cooperative worker timer. Build on what exists: `Grant::deadline` +(`rs/moq-auth/src/grant.rs`, #4237) already pins an accepted grant to a fixed +deadline, and dev's `moq_auth::lease` (#3943) re-checks a session on cadence +and reports why it ended. Extend those to the handles a grant opened rather +than adding a second timer. No clock-skew grace: open #4368 makes expiry +exact and drops `CLOCK_SKEW`, so a deadline in the past is expired. + +Cover an idle open handle, active source reads, active publication, refresh +before expiry, refresh after demand ends, disconnect races, HTTP and HLS rejection of the worker audience, and unrelated traffic continuing through an existing pooled HLS consumer after a worker grant expires. diff --git a/quest/m1/processor/media-contract.md b/quest/m1/processor/media-contract.md index 72d53ebec7..1bcf95eb5f 100644 --- a/quest/m1/processor/media-contract.md +++ b/quest/m1/processor/media-contract.md @@ -8,9 +8,14 @@ can resolve without eagerly subscribing to processor output. ## Plan -This quest also defines the generic catalog-level contribution reference -itself: a catalog entry that names another contribution and its relative -broadcast without opening it, which the processor contract specializes. +Build on hang's existing per-rendition relative `broadcast` reference +(`broadcast` on the video rendition in `rs/hang/src/catalog/video/mod.rs`, +with its audio, text, JSON, and binary counterparts): it already names another +broadcast relative to the catalog's, and Rust rejects one that escapes. The +processor output lives under the processor's prefix, mirroring the source +path, so the source catalog reaches it through that relative reference. Add +only what it lacks: a catalog-level contribution entry that names a whole +contribution without opening it, which the processor contract specializes. The processor publishes a normal Hang contribution rather than an arbitrary fragment for an edge to merge. Output renditions carry their own schema and an diff --git a/quest/m1/publish-codec-string.md b/quest/m1/publish-codec-string.md new file mode 100644 index 0000000000..b19ce6fe49 --- /dev/null +++ b/quest/m1/publish-codec-string.md @@ -0,0 +1,39 @@ +# [S] js/publish advertises the full codec string + +## Goal + +Every video rendition `@moq/publish` puts in the catalog carries a full +RFC 6381 codec string, so native players can decode what browsers publish. +Today the encoder probe in `js/publish/src/video/encoder.ts` falls back to the +bare hints `vp09`, `avc1`, `av01`, and `hev1` when no specific string is +supported, and the catalog publishes the hint as the codec. Rust `hang` parses +those as `VideoCodec::Unknown`, so moq-video and the other native consumers +refuse the track ([#4095](https://github.com/moq-dev/moq/pull/4095)). + +## Plan + +Decided: advertise the codec string from the encoder's own output, +`EncodedVideoChunkMetadata.decoderConfig.codec`, rather than the probe input. +Rust keeps refusing bare hints: the decoder gate needs the profile before it +subscribes. + +Guidance: + +- The catalog is built from the resolved config today, before any frame is + encoded. Either hold the rendition out of the catalog until the first + output reports its `decoderConfig`, or update it then; keep the stall and + jitter reporting working either way. Holding it out must go through the + reservation gate the #2075 quest adds to `#runCatalog`, or it recreates the + partial first snapshot that quest prevents. +- Check what each browser returns for a bare hint. If one echoes the hint + back, derive the string from the bitstream (SPS for H.264/H.265, the + sequence header for AV1, the uncompressed header for VP9), or fail loud + rather than publish a string no native player accepts. +- A reconfigure (resolution or codec change) can change the string; the + catalog follows it. +- Tests with the fake `VideoEncoder`: a bare-hint probe whose output reports + a full string publishes the full string. + +## Required + +- [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - the catalog reservation gate this rendition hold must go through diff --git a/quest/m1/publish-delay.md b/quest/m1/publish-delay.md deleted file mode 100644 index aeb4209d69..0000000000 --- a/quest/m1/publish-delay.md +++ /dev/null @@ -1,27 +0,0 @@ -# [S] js/publish: encoders advertise catalog delay - -## Goal - -The browser publisher advertises `delay` the way `moq-mux` does: each rendition -reports how far its minimum flush lateness trails the broadcast's earliest -rendition, as a lifetime maximum that is never lowered. A browser video encoder -running behind its audio encoder advertises that offset on video, so -`js/watch` holds it instead of playing video late. - -## Plan - -- `js/publish/src/jitter.ts` already measures each rendition's lateness on - `performance.now()`, and every js/publish timestamp shares that clock, so a - broadcast-wide sliding minimum beside the per-rendition one is enough. Mirror - `moq_mux::catalog::Estimator`: one window per rendition, one shared by the - broadcast, and `delay` is the gap between their minima. -- The shared minimum belongs to the `Broadcast` the encoders register on, not to - the encoders, so a swapped broadcast starts fresh. Keep it off the public API. -- The draft's rule stands: a consumer never subtracts `delay` across - renditions and holds the largest `delay + jitter` it subscribes to, so the - publisher reports its sliding-baseline maximum as is. -- `CatalogProducer` already refuses a lowered or zero `delay`. - -## Related - -- [Data jitter](/quest/m1/data-jitter.md) - the same measurement for JSON and binary tracks in Rust diff --git a/quest/m1/publish-lazy-file.md b/quest/m1/publish-lazy-file.md new file mode 100644 index 0000000000..fe1d333bd0 --- /dev/null +++ b/quest/m1/publish-lazy-file.md @@ -0,0 +1,36 @@ +# [S] Publish loads mediabunny only for file sources + +## Goal + +A `` element that captures a camera or screen no longer +downloads mediabunny. Today `js/publish/src/element.ts` statically imports +the sources, and `source/file.ts` imports `ALL_FORMATS` from mediabunny, so +every publish element pays about 99 KB gzip (379 KB minified). Of the element's 218 KB +gzip first load, that is the largest avoidable piece. + +## Plan + +Decided in planning: + +- Load the file source with a dynamic `import()` when a file source is + selected. +- Open the picker before anything is awaited: `File.prompt()` must run + synchronously in the click handler, and the decoder (mediabunny) loads + lazily once a `File` arrives. Awaiting the `import()` first spends the + click's transient user activation, so the browser blocks the picker + (Codex on [#4257](https://github.com/moq-dev/moq/pull/4257)). So the + picker stays in the eager bundle and only the decode path is lazy. +- Keep `ALL_FORMATS`, so any container mediabunny reads still works. Leave + mediabunny bundled into publish's dist rather than making it external. + +Guidance: + +- The public `@moq/publish` exports can still expose the file source + statically. Only the element and any default-source path need to stop + reaching it eagerly. +- Verify with a bundler metafile that the camera-only element no longer + contains mediabunny, and that picking a file still works in the demo. + +## Related + +- [Size report](/quest/m1/size-report.md) - tracks the publish element's first-load size diff --git a/quest/m1/qos/README.md b/quest/m1/qos/README.md index b6c84121b6..3bf61cf061 100644 --- a/quest/m1/qos/README.md +++ b/quest/m1/qos/README.md @@ -31,11 +31,21 @@ The counters and channels land here. The moq.pro (downstream) dashboard work, including the health badge, connection-health drill-down, and stream preflight, consumes them downstream. -## Quests +Decided (2026-09-28): the whole line, including the client stats line, targets +`dev`. The moq-stats schema change +([#4145](https://github.com/moq-dev/moq/pull/4145)) breaks the published +`moq-stats` crate, and a line cannot close with part of it on `main` and part +on `dev`. + +## Required - [Starvation](/quest/m1/qos/starvation.md) - per broadcast, how far behind the acknowledged frontier of its subscriptions is, in media time, plus the media dropped before it was acknowledged +- [Final lag sample](/quest/m1/qos/final-lag-sample.md) - a closing + subscription records its last partial interval instead of losing it +- [Lag dashboard](/quest/m1/qos/lag-dashboard.md) - the demo stats + dashboard shows viewer lag percentiles and dropped media - [Starvation at frame granularity](/quest/m1/qos/starvation-frames.md) - the acknowledged frontier moves at every frame boundary through `poll_acked`, with a delivery-delay histogram for jitter diff --git a/quest/m1/qos/final-lag-sample.md b/quest/m1/qos/final-lag-sample.md new file mode 100644 index 0000000000..6b4727e4d2 --- /dev/null +++ b/quest/m1/qos/final-lag-sample.md @@ -0,0 +1,38 @@ +# [XS] A closing subscription takes its last lag sample + +## Goal + +When an egress subscription ends, the bytes its track produced since the last +stats tick still land in the `lag` histogram at its final lag, instead of +being lost. A subscription that opens and closes between two ticks is sampled +at least once. + +## Plan + +Lag is sampled only on `Registry::report` ticks: `Counters::sample` in +`rs/moq-net/src/stats.rs` walks weak `FrontierInner` references and prunes +the dead ones without sampling them, so a subscription's last partial +interval disappears with it. + +- The subscription guard and each in-flight group `Delivery` hold a strong + reference, so the frontier dies only once the last of them does. Its drop is + the natural place for one final `sample(now)` into the same counters. +- No double counting: `sample` advances each source's `sampled` bytes under the + frontier lock, so a tick racing the drop leaves nothing for it to count again. +- Test: a subscription that opens and closes between ticks while its track + produces lands those bytes in the histogram; one that closes mid-interval + adds exactly the bytes since its last sample. Note the behaviour in + `doc/concept/stats.md`. + +Public API: none. Wire: none, only the histogram's values change. + +Overlaps the lag-splice quest on the line branch +([#4381](https://github.com/moq-dev/moq/pull/4381)), which also changes how +`FrontierInner::sample` holds `unsampled` weight and when a spliced segment's +source stops being sampled. Land them in sequence on the same sampler, not in +parallel, and make the final drop-time sample keep any weight lag-splice +defers. + +## Required + +- [Starvation](/quest/m1/qos/starvation.md) - the sampler this extends (#4298) diff --git a/quest/m1/qos/lag-dashboard.md b/quest/m1/qos/lag-dashboard.md new file mode 100644 index 0000000000..c89e45f38f --- /dev/null +++ b/quest/m1/qos/lag-dashboard.md @@ -0,0 +1,34 @@ +# [S] Demo dashboard shows viewer lag + +## Goal + +The demo stats dashboard (`demo/web/src/stats.ts`) shows the relay's viewer +lag and dropped media: percentiles of the egress `lag` histogram over the +chart window, and `dropped` duration, bytes, and groups as rates, cluster-wide +and per node like the existing counters. + +## Plan + +- The relay writes both on `publisher.json` rows only. `lag` is a cumulative + byte count per bucket keyed by its upper edge (`"50ms"` to `"5s"`, then + `"inf"`, empty buckets omitted); `dropped` is `{ duration, bytes, groups }` + with the duration in fractional milliseconds. `doc/concept/stats.md` on the + QoS line documents them. Diff two samples for an interval's distribution, + as the dashboard already does to turn cumulative bytes into rates, and sum + nodes bucket by bucket. +- A percentile read from buckets is a bucket edge, not a point value, and + `inf` has no upper edge. Show it as "under X" or interpolate inside the + bucket, and say which. +- Skip `.`-prefixed system broadcasts, as the existing aggregate does. Lag is + per broadcast, so a per-broadcast view is worth adding if it stays cheap. +- The [browser stats quest](/quest/m1/qos/stats/js.md) moves the dashboard + onto `@moq/stats` on the same line. If it has landed, read `lag` and + `dropped` through its schemas; otherwise extend the existing interfaces and + let whichever lands second reconcile. + +Public API: none. Wire: none. + +## Required + +- [Starvation](/quest/m1/qos/starvation.md) - the `lag` histogram and + `dropped` counters (#4298) diff --git a/quest/m1/qos/stats/README.md b/quest/m1/qos/stats/README.md index c888d094e0..b74ec7f73f 100644 --- a/quest/m1/qos/stats/README.md +++ b/quest/m1/qos/stats/README.md @@ -46,9 +46,10 @@ Decisions settled while planning: authorization, or route-selection input. - **A published break goes through dev**, because `moq-stats` is a published crate and the generic producer is a breaking change, and the moq-json rework there is what the - producers build on. + producers build on. The parent QoS line targets `dev` with it (#4145), so + every quest here does too. -## Quests +## Required - [Schema and library](/quest/m1/qos/stats/schema.md) - moq-stats takes an extension, serves per-broadcast tracks, and hang defines the media stats @@ -58,7 +59,3 @@ Decisions settled while planning: crate, and the browser publisher and player report through it - [Encoder feedback](/quest/m1/qos/stats/encoder-feedback.md) - a Rust encoder subscribes to its viewers' stats and adapts its bitrate - -## Closes - -- [#3608](https://github.com/moq-dev/moq/issues/3608) - close this issue when the questline finishes diff --git a/quest/m1/qos/stats/encoder-feedback.md b/quest/m1/qos/stats/encoder-feedback.md index 55d5cbee83..f6391b0b7c 100644 --- a/quest/m1/qos/stats/encoder-feedback.md +++ b/quest/m1/qos/stats/encoder-feedback.md @@ -27,7 +27,9 @@ prefix. Keyframe requests stay out. a stalled share above a threshold steps the target down like a bandwidth drop, recovery follows the existing attack curve, and the estimate stays the ceiling. - Audio follows the same signal with its narrower ladder. + Audio does not follow its grant today (`Options::bandwidth` in + `rs/moq-audio/src/encode/producer.rs` reserves only), so it follows this + signal only once the grant quest lands; until then the loop drives video. - `moq import --feedback ` and `moq transcode --feedback ` wire it; each rung of the ladder reads its own broadcast's track. - Test with the CLI publishing to a relay and two `moq play --stats` viewers @@ -46,3 +48,5 @@ prefix. Keyframe requests stay out. - [Ladder](/quest/m1/ladder/README.md) - the transcode ladder that adapts to its uplink today +- [Audio follows the grant](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - + the audio rate follow this signal would feed diff --git a/quest/m1/qos/stats/js.md b/quest/m1/qos/stats/js.md index 4a95727e1f..280092e32c 100644 --- a/quest/m1/qos/stats/js.md +++ b/quest/m1/qos/stats/js.md @@ -35,7 +35,3 @@ dashboard reads relay stats through the package instead of its own copies. - [Schema and library](/quest/m1/qos/stats/schema.md) - the wire shape this mirrors - -## Closes - -- [#2735](https://github.com/moq-dev/moq/issues/2735) - close this issue when the quest finishes diff --git a/quest/m1/qos/stats/rust.md b/quest/m1/qos/stats/rust.md index 4073c38fa2..2a9f7ebb07 100644 --- a/quest/m1/qos/stats/rust.md +++ b/quest/m1/qos/stats/rust.md @@ -36,7 +36,3 @@ publisher of a `.hang` broadcast can learn whether its viewers played it. - [Schema and library](/quest/m1/qos/stats/schema.md) - the producer and the media types - -## Closes - -- [#2734](https://github.com/moq-dev/moq/issues/2734) - close this issue when the quest finishes diff --git a/quest/m1/quic/README.md b/quest/m1/quic/README.md index f3234590a4..36c4c19aa2 100644 --- a/quest/m1/quic/README.md +++ b/quest/m1/quic/README.md @@ -8,10 +8,10 @@ MoQ's schedule and offers it upstream when it is general. One core serves the tokio backend, the thread-per-core `moq-uring` backend, iroh, and qmux. The features are per-stream acknowledgment progress, reliable stream resets, hierarchical stream scheduling with per-broadcast fairness, the shared stream -state machine used by qmux, capacity probing for media, per-stream -deadlines, deadline-based and wider limits for relay peers. The experiments that may join them (GCC, FEC, receive -timestamps, kernel pacing, buffer pools, probing, L4S, careful resume) live -in [m2](/quest/m2/README.md). +state machine used by qmux, per-stream deadlines, and wider limits for relay +peers. The experiments that may join them (GCC, FEC, receive timestamps, +kernel pacing, buffer pools, media probing, L4S, careful resume, deadline +keep-alive) live in [m2](/quest/m2/README.md) and do not gate this line. ## Plan @@ -20,15 +20,15 @@ One stack carries every change on MoQ's own QUIC paths; a build with the `iroh` feature also compiles upstream noq, and iroh connections are outside what these quests reach. -The seven BBR correctness fixes follow the fork bootstrap. They are separate -PRs, but one owner should work in the shared controller code at a time. -Their controller-level regressions extend the shared test `Sim` in -`bbr3/mod.rs` with only what each needs, rather than adding another -simulation loop; a fix at the transport boundary still needs a transport -test through the real callbacks. The existing loops stay, since the fork -merges upstream weekly and a port would conflict. -The [BBR release](/quest/m1/quic/bbr-release.md) delivers them without waiting -for the remaining transport features. The +The seven BBR correctness fixes shipped in moq-noq 1.3.1 (#4206). The +remaining BBR quests here and [BBR ACK cleanup](/quest/m1/bbr-ack-cleanup.md) +all edit `bbr3/mod.rs`, so one owner should work there at a time. +Controller-level regressions extend the shared test `Sim` in `bbr3/mod.rs` +with only what each needs, rather than adding another simulation loop; a fix +at the transport boundary still needs a transport test through the real +callbacks. The existing loops stay, since the fork merges upstream weekly and +a port would conflict. Each BBR fix ships in a fork patch release without +waiting for the remaining transport features. The [Google comparison](/quest/m2/quic-bbr-google.md) is a separate study. Rules the line keeps: @@ -49,16 +49,9 @@ session with fairness enabled, the send group is the broadcast. The default MoQ order is newest group first; an ordered subscription keeps oldest first. This is a transport API change, not a MoQ wire change. -## Quests +## Required -- [Preserve QUIC packet identity in BBR](/quest/m1/quic/bbr-packet-identity.md) - ACKs and losses identify the right packet across QUIC spaces -- [Finish each BBR ACK sample before using it](/quest/m1/quic/bbr-ack-sampling.md) - current delivery samples reach the model once with consistent metadata -- [Mark application starvation before the next BBR send](/quest/m1/quic/bbr-app-limited.md) - resumed bursts retain correct sample labels -- [Finish BBR bandwidth-probe feedback once](/quest/m1/quic/bbr-probe-feedback.md) - cruise rounds neither age probe history repeatedly nor retain probe-loss classification -- [Recalibrate BBR startup pacing from measured RTT](/quest/m1/quic/bbr-startup-pacing.md) - measured RTT replaces the nominal startup rate for media senders -- [Protect bandwidth samples during BBR ProbeRTT](/quest/m1/quic/bbr-probe-rtt.md) - intentionally reduced sending cannot masquerade as reduced capacity -- [Preserve BBR state across a spurious loss episode](/quest/m1/quic/bbr-loss-undo.md) - consecutive losses preserve the original recovery snapshot -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - publish and pin the corrected controller independently of later features +- [BBR idle burst](/quest/m1/quic/bbr-app-limited.md) - a fork regression proves a burst after a long idle is paced at the learned bandwidth, closing #4219 - [Align BBR loss handling with draft-06](/quest/m1/quic/bbr-loss-parity.md) - losses use their own sample and undo re-enters ProbeUp through Refill - [Mark BBR starvation wherever the source runs dry](/quest/m1/quic/bbr-app-limited-edges.md) - partial polls count, local send caps do not, receiver credit is pinned - [Deliver the application close before io_uring teardown](/quest/m1/quic/uring-close.md) - @@ -99,7 +92,7 @@ This is a transport API change, not a MoQ wire change. - [FEC experiment](/quest/m2/quic-fec.md) - a measured verdict on transport redundancy - [Kernel pacing](/quest/m2/quic-kernel-pacing.md), [Send batching](/quest/m2/quic-send-batching.md), - [Send buffer pools](/quest/m2/quic-buffer-pool.md), [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - + [Send buffer pools](/quest/m2/quic-buffer-pool.md), [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - the syscall, allocation, and controller spikes - [Multipath spike](/quest/m2/multipath-spike.md) - a noq capability that MoQ does not use yet diff --git a/quest/m1/quic/bbr-ack-sampling.md b/quest/m1/quic/bbr-ack-sampling.md deleted file mode 100644 index 63fcb2b098..0000000000 --- a/quest/m1/quic/bbr-ack-sampling.md +++ /dev/null @@ -1,35 +0,0 @@ -# [M] Finish each BBR ACK sample before using it - -## Goal - -Each ACK updates the BBR model and control parameters from a completed, -consistent sample. Bandwidth, delivered bytes, application-limited status, -and inflight describe the same observation. - -## Plan - -In [the audited callback order](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1656), on_ack updates the model per -packet before on_end_acks computes delivery_rate and delivered. Current -packet metadata is combined with the preceding ACK's values. Follow the -ordering in [draft section 5.2.3](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.2.3) and -[Google QUICHE](https://github.com/google/quiche/blob/535a2730e77d47e0dc03746555cc9c34b17bc9e9/quiche/quic/core/congestion_control/bbr2_sender.cc#L271), adapted to noq's event boundary. - -Reproduce both failures in the fork: the first completed 1200-byte/10-ms -sample leaves max_bw at zero; and, with an aged prior bandwidth maximum, a -120 KB/s application-limited sample is admitted under the next burst's -non-limited metadata even though that burst delivers 1.2 MB/s. Verify fresh -samples are consumed once, with the correct labels and post-ACK inflight. - -Cover batched ACKs, reordered ACKs, invalid/too-short intervals, loss-only -events, and idle restart. Preserve the transport's RTT and loss event -ordering while fixing the source of stale state. Reuse the existing -controller simulations and add regressions to the fork's CI. Coordinate -with packet identity work if the callback contract changes; settle the API -with the maintainer and update its consumers and docs in the same PR. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic -- [Packet identity](/quest/m1/quic/bbr-packet-identity.md) - shares the controller boundary diff --git a/quest/m1/quic/bbr-app-limited-edges.md b/quest/m1/quic/bbr-app-limited-edges.md index 58a62a00fa..fc47d4d255 100644 --- a/quest/m1/quic/bbr-app-limited-edges.md +++ b/quest/m1/quic/bbr-app-limited-edges.md @@ -29,11 +29,10 @@ when a transmit poll sends nothing. In moq-dev/noq Transport-boundary tests through the real callbacks: a partial poll that drains, a sender blocked only by `send_window`, and a stream blocked by -receiver credit. Stacks on the seven fixes' noq branches until they merge; -does not gate [the BBR release](/quest/m1/quic/bbr-release.md). No public -API or wire change is intended. +receiver credit. Builds on the seven fixes released in moq-noq 1.3.1. No public API or wire +change is intended. ## Related -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - includes the first app-limited fix this extends -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measures natural draining on the corrected controller +- [BBR idle burst](/quest/m1/quic/bbr-app-limited.md) - the first app-limited fix this extends +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - measures natural draining on the corrected controller diff --git a/quest/m1/quic/bbr-app-limited.md b/quest/m1/quic/bbr-app-limited.md index dca67a99a8..f0ea70c90c 100644 --- a/quest/m1/quic/bbr-app-limited.md +++ b/quest/m1/quic/bbr-app-limited.md @@ -1,38 +1,39 @@ -# [M] Mark application starvation before the next BBR send +# [S] Prove a BBR burst after a long idle is paced at the learned bandwidth ## Goal -Packets sent after application starvation carry the correct historical -application-limited label, even when no ACK arrives during the idle gap. -The bandwidth model cannot mistake a source-limited sample for capacity. +After minutes of keep-alive-only idle, a BBRv3 (`delay`) sender paces its +next burst near the bandwidth it learned before, not at a trickle, and a +regression test in the fork proves it. ## Plan -In noq `1a26a8b064d21e316fe6769f068617975bd8a27b`, an empty unblocked -[transmit poll](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/connection/mod.rs#L1337) records starvation, but BBR receives the marker only in -[on_end_acks](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1732). -A resumed send can be stamped before that notification. Google's -[QUICHE BBR3](https://github.com/google/quiche/blob/535a2730e77d47e0dc03746555cc9c34b17bc9e9/quiche/quic/core/congestion_control/bbr3_sender.cc#L505) notifies its sampler immediately when application limited. - -Reproduce through the transport boundary: send and ACK one 1200-byte packet -with a 10-ms RTT, run an empty unblocked transmit poll, wait until 30 ms to -send another packet, then ACK it 10 ms later. There is no intervening ACK -to notify the controller of starvation. The existing public-callback -reproduction yields a non-limited sample; preserve a failing regression -without privately seeding the sampler marker. This establishes a label bug, -not a measured throughput regression. - -Communicate starvation before subsequent sends, preserving the delivery -boundary that ends the sampler's limited phase. Distinguish producer -starvation from cwnd, pacing, anti-amplification, receiver credit, and local -buffer limits; do not silently change receiver-limited policy. Cover streams, -datagrams, resumed backlog, repeated empty polls, and ACK batching. Coordinate -any controller event changes with the packet identity and ACK sampling fixes. -Keep state private where possible; document any public Controller change and -its consumers. No wire change is intended. Wire regressions into fork CI. +moq-dev/noq#5 (in moq-noq 1.3.1; main pins 1.3.2) fixed the label bug this +quest was opened for. The transport calls `Controller::on_app_limited` on +every empty poll that nothing held back, and BBR marks starvation before the +next send. Its tests cover streams, datagrams, batched ACKs, and backlogs +held by the window or the pacer. What is left is +[#4219](https://github.com/moq-dev/moq/issues/4219), reported on 1.3.0 and +not yet reproduced on either version: the first 250 KB after five idle +minutes took 5.2 s at 50 ms RTT, against 0.5 s with CUBIC. + +The fix plausibly covers it. The fork's max-bw filter ages only on +non-app-limited samples, and before #5 every other keep-alive was stamped +non-app-limited. Nothing measures it, though. Add a virtual-time transport +test in the fork: learn the bandwidth, idle on keep-alives for about five +minutes, send 250 KB, and assert the pacing rate stays at or above about +0.9x the earlier max bandwidth. Check that it fails on 1.3.0. + +If it still stalls, find the remaining cause, such as ProbeRTT entered during +the idle or a stale `bw_shortterm`, and fix it in the fork. Dropping the +estimate after a long idle is a policy change for the m2 study, not this +quest. The `iroh` feature uses upstream noq, which lacks #5; 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 -- [Finish each BBR ACK sample](/quest/m1/quic/bbr-ack-sampling.md) - a separate ordering defect in the same callback lifecycle -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver correct labels before policy experiments -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer the general fix upstream +- [Upstream the fork](/quest/m1/quic/upstream.md) - offer the starvation fix upstream diff --git a/quest/m1/quic/bbr-loss-parity.md b/quest/m1/quic/bbr-loss-parity.md index 647d1123a5..7b36bf4f53 100644 --- a/quest/m1/quic/bbr-loss-parity.md +++ b/quest/m1/quic/bbr-loss-parity.md @@ -33,5 +33,4 @@ no `Controller` or wire change. ## Related -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - the corrected baseline this builds on - [Upstream the fork](/quest/m1/quic/upstream.md) - offers this fix alongside the seven diff --git a/quest/m1/quic/bbr-loss-undo.md b/quest/m1/quic/bbr-loss-undo.md deleted file mode 100644 index ceae058e03..0000000000 --- a/quest/m1/quic/bbr-loss-undo.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] Preserve BBR state across a spurious loss episode - -## Goal - -Declaring a loss episode spurious restores the state saved before that -episode, even when several packets were declared lost. Later losses in the -same episode cannot overwrite the original recovery snapshot. - -## Plan - -[note_loss](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1513) saves undo state on every lost packet, including -after an earlier packet reduced the model. With a 100,000-byte long-term -inflight bound, a 10,000-byte BDP, a 20,000-byte cwnd, and two successive -1200-byte packet losses during ProbeUp, undo retained 7000 bytes rather than -the original bound. Existing single-loss simulations miss this case. - -Align snapshot lifetime with recovery semantics using -[Google Linux's recovery entry](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L2240) and -[draft section 5.5.11](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.5.11). Verify saved model bounds, -window, and any restorable phase across multiple losses in one event and -across ACK events. A new episode must get a new snapshot; real loss must -still constrain sending. Cover ProbeRTT interaction and add regressions to -the fork's CI. Keep the fix internal with no wire change. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-packet-identity.md b/quest/m1/quic/bbr-packet-identity.md deleted file mode 100644 index c3ecbc27f8..0000000000 --- a/quest/m1/quic/bbr-packet-identity.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] Preserve QUIC packet identity in BBR - -## Goal - -BBR associates every send, ACK, and loss with the correct packet across -Initial, Handshake, and application-data spaces. Reused packet numbers never -alias or invalidate an ordered lookup. - -## Plan - -The audit of noq-proto 1.3.0 and upstream `1a26a8b` found separate internal -packet queues, but [every public Controller callback forces Data](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1881). -Sending Initial packets 0 and 1 followed by Handshake packet 0 makes an ACK -for the Handshake packet select Initial packet 0 instead. - -Fix identity at the transport/controller boundary in moq-dev/noq. Cover -coalesced sends, repeated packet numbers across spaces, losses, key discard, -and path ownership through the real transport callbacks. Private helpers -that accept a space while the public path discards it are insufficient. - -Land a regression that sends the overlapping sequence above and verifies the -ACK uses the Handshake packet's send time and delivery snapshot. Check every -controller consumer if the public Controller contract changes, including -other controllers and custom implementations. Settle any public API change -with the maintainer before implementation; document it inline. No QUIC wire -change is intended. Run the regressions in the fork's CI. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-probe-feedback.md b/quest/m1/quic/bbr-probe-feedback.md deleted file mode 100644 index 7dc6f54b08..0000000000 --- a/quest/m1/quic/bbr-probe-feedback.md +++ /dev/null @@ -1,28 +0,0 @@ -# [S] Finish BBR bandwidth-probe feedback once - -## Goal - -Finishing a bandwidth probe advances the bandwidth-history window once. -Later cruise losses are not classified as feedback from that probe. - -## Plan - -[adapt_long_term_model](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L991) leaves ack_phase at ProbeStopping and -bw_probe_samples set. Every subsequent cruise round can advance cycle_count, -and a later loss can reduce the long-term model as if it came from probing. -Compare [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L1664), -[Google QUICHE](https://github.com/google/quiche/blob/535a2730e77d47e0dc03746555cc9c34b17bc9e9/quiche/quic/core/congestion_control/bbr2_probe_bw.cc#L119), and the draft's -AdaptLongTermModel transition. - -Exercise a completed loss-free probe followed by several cruise rounds. -Assert the filter advances only once and retains the intended probe-cycle -history. Inject a later cruise loss and verify only the appropriate -short-term response applies. Include application-limited feedback and -ProbeRTT entry so neither leaves stale probe classification. Land these -regressions in the fork's CI without changing public APIs or the wire. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-probe-rtt.md b/quest/m1/quic/bbr-probe-rtt.md deleted file mode 100644 index 955816ea5e..0000000000 --- a/quest/m1/quic/bbr-probe-rtt.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] Protect bandwidth samples during BBR ProbeRTT - -## Goal - -Packets deliberately rate-limited by ProbeRTT cannot lower the bandwidth -model as if they measured a network capacity reduction. Normal sampling -resumes after the protected delivery interval. - -## Plan - -[handle_probe_rtt](https://github.com/n0-computer/noq/blob/1a26a8b064d21e316fe6769f068617975bd8a27b/noq-proto/src/congestion/bbr3/mod.rs#L1182) omits the application-limited marker present -in [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L920) and -[draft section 5.3.4.3](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.3.4.3). The audited controller -sends unmarked packets during ProbeRTT even with a backlogged application. -QUICHE's BBR3 ProbeRTT also lacks an explicit marker; the requirement here -is the draft/Linux behavior, not universal Google parity. - -Reproduce that unmarked send, then test entry, the full ProbeRTT interval, -exit, and delayed ACKs for packets sent during the interval. Verify reduced -samples do not replace a learned capacity solely because ProbeRTT reduced -the window, while a legitimate higher sample can still raise the model. -Do not mark the connection application-limited forever or change the -ProbeRTT policy merely to mask a sampling bug. Add the regressions to the -fork's CI; keep the fix internal with no wire change. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic diff --git a/quest/m1/quic/bbr-release.md b/quest/m1/quic/bbr-release.md deleted file mode 100644 index 8966e3ae3d..0000000000 --- a/quest/m1/quic/bbr-release.md +++ /dev/null @@ -1,36 +0,0 @@ -# [M] Release the BBR correctness fixes - -## Goal - -Published MoQ consumers receive the seven corrected BBR behaviors through -immutable releases of the noq fork and its adapters. Fixes do not wait for -qmux, stream scheduling, or other unrelated QUIC features. - -## Plan - -After the fork bootstrap, publish the corrected dependency chain and update -MoQ's manifest and lockfile pins. Record the parent commit, carried patches, -and upstream status. Follow the existing fork packaging rules; no mutable -branch or workspace-only patch may stand in for a release. - -Verify the fork's regression suite and MoQ's default and supported runtime -builds against the released artifacts, including compatibility of controller -consumers if a callback API changed. Run a media-shaped transfer through the -real QUIC stack to confirm handshake sampling, pacing, and recovery work -together. This integration check is required; the broader media-flow study -and Google comparison do not gate these bug fixes. - -## Required - -- [Preserve QUIC packet identity in BBR](/quest/m1/quic/bbr-packet-identity.md) -- [Finish each BBR ACK sample before using it](/quest/m1/quic/bbr-ack-sampling.md) -- [Mark application starvation before the next BBR send](/quest/m1/quic/bbr-app-limited.md) -- [Finish BBR bandwidth-probe feedback once](/quest/m1/quic/bbr-probe-feedback.md) -- [Recalibrate BBR startup pacing from measured RTT](/quest/m1/quic/bbr-startup-pacing.md) -- [Protect bandwidth samples during BBR ProbeRTT](/quest/m1/quic/bbr-probe-rtt.md) -- [Preserve BBR state across a spurious loss episode](/quest/m1/quic/bbr-loss-undo.md) - -## Related - -- [Release the stack](/quest/m1/quic/release.md) - later feature releases follow the same packaging rules -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - broader media measurements after the corrected release diff --git a/quest/m1/quic/bbr-startup-pacing.md b/quest/m1/quic/bbr-startup-pacing.md deleted file mode 100644 index 13f6adfe69..0000000000 --- a/quest/m1/quic/bbr-startup-pacing.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] Recalibrate BBR startup pacing from measured RTT - -## Goal - -The first available measured RTT replaces BBR's nominal 1-ms startup -pacing estimate. A media sender that stays application-limited does not keep -an inflated pacing rate for its entire session. - -## Plan - -The audited 12-KB initial window retains a 33,276,000-byte/s pacing rate -after a measured 10-ms RTT. Startup only raises its rate, and an -application-limited flow need not leave Startup. [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L443) -reinitializes pacing once an RTT measurement becomes available. - -Review and reuse [upstream PR #802](https://github.com/n0-computer/noq/pull/802) -if still applicable, preserving its author's attribution rather than -reimplementing it. Check the actual ACK/RTT callback order: the configured -initial RTT is not evidence of a measurement, and the first ACK can precede -the transport RTT update. Recalculate the send quantum consistently. - -Add CI regressions for measured RTTs above and below 1 ms, a continuously -application-limited source, and a secondary path initialized after the -handshake. Preserve subsequent bandwidth-driven Startup growth. Report any -public RTT-estimator API change and document it inline; no wire change is -intended. - -## Related - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - deliver the corrected controller to MoQ -- [Upstream the fork](/quest/m1/quic/upstream.md) - offer general fixes upstream -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - measure the corrected controller on media traffic -- [noq #800](https://github.com/n0-computer/noq/issues/800) - existing upstream report; do not duplicate it diff --git a/quest/m1/quic/deadline.md b/quest/m1/quic/deadline.md index 4c518af2e4..8450cadfa4 100644 --- a/quest/m1/quic/deadline.md +++ b/quest/m1/quic/deadline.md @@ -30,9 +30,10 @@ Implement in the fork. ACK-frequency extension noq already implements) on the next packet and arm a shortened probe at `max(deadline - rtt - now, min_pto)`. Never probe past the congestion window; the probe is a scheduling choice, not extra credit. -- moq-net: `Subscription::serve_group` sets the deadline from the - subscription's latency target and the group's expiry, whichever is sooner; - a subscription with neither sets none. The reset error code maps to the +- moq-net: the per-group `GroupServe` machine in the lite and IETF + publishers (`lite/publisher.rs`, `ietf/publisher.rs`) sets the deadline + when it opens the stream, from the subscription's latency target and the + group's expiry, whichever is sooner; a subscription with neither sets none. The reset error code maps to the existing group-expired code on the MoQ wire, so a viewer sees the same signal it sees for a relay-side expiry today. diff --git a/quest/m1/quic/ecn-measure.md b/quest/m1/quic/ecn-measure.md index 56b6658477..2745829780 100644 --- a/quest/m1/quic/ecn-measure.md +++ b/quest/m1/quic/ecn-measure.md @@ -14,7 +14,8 @@ 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 +Use moq-noq 1.3.2 or later, which carries the classic BBR CE response +([moq-dev/noq#12](https://github.com/moq-dev/noq/pull/12)), 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. @@ -35,7 +36,3 @@ from 1.3.1 is a defective baseline, not evidence that classic ECN cannot help. 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/peer-limits.md b/quest/m1/quic/peer-limits.md index 88eecc44be..7c36f74bc2 100644 --- a/quest/m1/quic/peer-limits.md +++ b/quest/m1/quic/peer-limits.md @@ -30,7 +30,8 @@ Tests: a cluster session sees the raised `MAX_STREAMS` after SETUP and a viewer session does not; the io_uring path applies the same values; a `peer` table below the defaults is refused at resolve time. -## Related +## Required - [io_uring flow control](/quest/m1/uring-flow-control-windows.md) - the - static windows on the same workers + io_uring workers hardcode their windows today, so the peer values have + nothing to raise there until the static windows reach them diff --git a/quest/m1/quic/release.md b/quest/m1/quic/release.md index d2f3878932..fee1d41bdf 100644 --- a/quest/m1/quic/release.md +++ b/quest/m1/quic/release.md @@ -24,8 +24,6 @@ the parent applies. ## Required -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - preserve the corrected controller in later stack releases - - [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - the WebTransport-required transport extension - [Hierarchical stream scheduling](/quest/m1/quic/scheduler.md) - the new diff --git a/quest/m1/quic/scheduler.md b/quest/m1/quic/scheduler.md index 8e2ceaafe9..58bcb30b19 100644 --- a/quest/m1/quic/scheduler.md +++ b/quest/m1/quic/scheduler.md @@ -33,12 +33,15 @@ the group's turn, and opening newer groups must not reset its accumulated fair-share credit. Map conventions only at adapters. MoQ's model remains higher value first, the -IETF wire remains lower value first, and browser `sendOrder` remains local to -its WebTransport send group. Native QUIC and qmux use the full three levels; -a browser that cannot prioritize send groups gets the lower two levels without -pretending to provide strict subscription priority. - -Give every MoQ subscription one send group. A SUBSCRIBE_UPDATE changes the +IETF wire remains lower value first. Native QUIC and qmux use the full three +levels. Browsers (js/net and web-transport-wasm) never create send groups: +browser groups are flat and byte-fair, which would trade strict priority +between subscriptions (audio over video) for fairness nobody on a browser +session needs, so they keep the default group and pack priority and group +order into `sendOrder` (decided 2026-09-26 with +[Firefox 155](/quest/m2/firefox-155-webtransport.md)). + +Give every MoQ subscription one native send group. A SUBSCRIBE_UPDATE changes the group priority atomically. Group streams use their position within the subscription, never another subscription's sequence. Remove the session-wide `lite::PriorityQueue` once every enabled backend has an honest implementation diff --git a/quest/m1/quic/upstream.md b/quest/m1/quic/upstream.md index 320c4d357d..294e9c0e44 100644 --- a/quest/m1/quic/upstream.md +++ b/quest/m1/quic/upstream.md @@ -15,7 +15,7 @@ lands in the fork on MoQ's schedule; once a feature has shipped in a MoQ release and its shape has stopped moving, split it into an upstream PR with the tests it landed with. -Offer the seven [BBR correctness fixes](/quest/m1/quic/bbr-release.md) and +Offer the seven BBR correctness fixes (moq-noq 1.3.1, #4206) and their [loss](/quest/m1/quic/bbr-loss-parity.md) and [starvation](/quest/m1/quic/bbr-app-limited-edges.md) follow-ups with their regressions before promoting BBR as the default. Reuse existing @@ -36,17 +36,16 @@ Then the feature proposal order, each linked to its producing quest: on, so its regressions are found upstream and not only here; 1. per-stream acknowledgment progress ([ACK progress](/quest/m1/quic/ack-progress.md)); 2. `RESET_STREAM_AT` ([reliable reset](/quest/m1/quic/reliable-reset.md)); -3. keep-alive by deadline ([keep-alive](/quest/m2/quic-keep-alive.md)); -4. hierarchical send groups ([scheduler](/quest/m1/quic/scheduler.md)); -5. careful resume as a `Controller` wrapper ([careful resume](/quest/m2/quic-careful-resume.md)); -6. ECT(1) marking and its accounting ([L4S](/quest/m2/quic-ecn.md)); -7. per-stream deadlines ([deadlines](/quest/m1/quic/deadline.md)); -8. the measured media-headroom mechanism ([probe](/quest/m2/quic-probe.md)); -9. the qmux crate over the shared stream state machine ([qmux](/quest/m1/quic/qmux.md)). +3. hierarchical send groups ([scheduler](/quest/m1/quic/scheduler.md)); +4. per-stream deadlines ([deadlines](/quest/m1/quic/deadline.md)); +5. the qmux crate over the shared stream state machine ([qmux](/quest/m1/quic/qmux.md)). -The next experiments (receive timestamps, GCC, FEC, kernel pacing, send -batching, buffer pools, the BBR3 app-limited check) join the list only with -a positive verdict. +The m2 features (keep-alive by deadline, careful resume as a `Controller` +wrapper, ECT(1) marking, the media-headroom mechanism) are offered when they +land, but do not gate this quest: an m1 quest must not wait on m2 work. The +next experiments (receive timestamps, GCC, FEC, kernel pacing, send +batching, buffer pools, the natural-drain check) join the list only with a +positive verdict. Record in this quest what upstream accepted, what it asked to see as an extension crate, and what it declined; a declined change stays in the fork @@ -55,22 +54,22 @@ offered and answered. ## Required -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - the corrected controller and its regression evidence - [Align BBR loss handling with draft-06](/quest/m1/quic/bbr-loss-parity.md) - [Mark BBR starvation wherever the source runs dry](/quest/m1/quic/bbr-app-limited-edges.md) - [Per-stream ACK progress](/quest/m1/quic/ack-progress.md) - [Reliable stream reset](/quest/m1/quic/reliable-reset.md) -- [Keep-alive by deadline](/quest/m2/quic-keep-alive.md) - [Hierarchical stream scheduling](/quest/m1/quic/scheduler.md) -- [Careful resume on reconnect](/quest/m2/quic-careful-resume.md) -- [L4S on the backbone](/quest/m2/quic-ecn.md) - [Per-stream deadlines](/quest/m1/quic/deadline.md) -- [Discover media headroom](/quest/m2/quic-probe.md) - [qmux on the QUIC stream state machine](/quest/m1/quic/qmux.md) ## Related - [Receive timestamps](/quest/m2/quic-receive-ts.md), [GCC](/quest/m2/quic-gcc.md), - [FEC](/quest/m2/quic-fec.md), [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - + [FEC](/quest/m2/quic-fec.md), [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - experiments that join the list with a positive verdict +- [Keep-alive by deadline](/quest/m2/quic-keep-alive.md), + [Careful resume on reconnect](/quest/m2/quic-careful-resume.md), + [L4S on the backbone](/quest/m2/quic-ecn.md), + [Discover media headroom](/quest/m2/quic-probe.md) - m2 features offered + upstream when they land diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md new file mode 100644 index 0000000000..0d9309002a --- /dev/null +++ b/quest/m1/raw-stream-codes.md @@ -0,0 +1,38 @@ +# [M] Raw QUIC stream codes stay raw + +## Goal + +On a raw QUIC session (`moqt://`, `moql://`, raw iroh), RESET_STREAM and +STOP_SENDING carry the application's code as-is, not mapped through the +HTTP/3 WebTransport code space, so moq agrees with other raw QUIC MoQ stacks +on stream errors. WebTransport sessions keep the mapping they need. + +## Plan + +- `web-transport-moq` (moq-dev/noq) runs every stream code through + `web_transport_proto::error_to_http3` and `error_from_http3` in `send.rs` + and `recv.rs`, whether or not the session came from `Session::raw`. + `web-transport-iroh` and `web-transport-quinn` (moq-dev/web-transport) do + the same. A raw peer's code 5 reads as `None` or another value, and ours + reaches it as a large HTTP/3 code. +- #4262 fixed the same mix-up for `ApplicationClosed` on `dev`, on + web-transport-trait 0.5, so this follows it there. Let each stream know whether its + session is raw and skip the mapping there, in all three adapters, with a + round-trip test per adapter against a plain QUIC peer. +- Release the fixed crates and bump the pins here in the same quest; published + crates depend on crates.io releases, never a patch. A moq-tokio test over + `moqt://` asserts a reset code arrives verbatim, beside `close_code.rs`. +- Mixed versions: two moq peers on raw QUIC agree today because both map, and + wire changes must stay compatible with published versions. An older peer + still sends `error_to_http3(code)`, so a fixed peer that only skips the + mapping reports the large HTTP/3 value, not the code. The legacy form is + detectable, since it lands in the HTTP/3 WebTransport range that no moq + code reaches (`error_from_http3` returns `None` outside it), so the raw + receive path unmaps a code in that range and passes the rest through, with + a legacy-sender test per adapter. The other direction has no fix at the + receiver: an older peer misreads a fixed peer's raw codes. Check which codes + moq-net acts on (group stream resets, subscribe STOP_SENDING): if any drives + behaviour beyond reporting, this is a wire break and retargets to `dev`. + +Public API: none expected. Wire: raw QUIC stream error codes become the +application's own values; compatible only if older peers merely report them. diff --git a/quest/m1/redirect-resolve.md b/quest/m1/redirect-resolve.md new file mode 100644 index 0000000000..7ee9ef7014 --- /dev/null +++ b/quest/m1/redirect-resolve.md @@ -0,0 +1,30 @@ +# [XS] Strict or private Redirect::resolve + +## Goal + +`moq_tokio::Redirect` no longer offers a public way to turn a refused or +malformed GOAWAY redirect into "dial the current address". A caller either +gets the same refusal `Connection` acts on, or cannot call it at all. + +## Plan + +The drain line (https://github.com/moq-dev/moq/pull/4143) made +`Connection` end with `Error::RefusedRedirect` on a malformed or +policy-refused URI instead of redialing the peer that asked it to leave, and +made a certificate pin refuse a host change. It left the public +`Redirect::resolve` lenient to avoid a break: it falls back to the current +URL on any refusal and never sees the pin, so it answers differently from +the connection it documents. + +Recommendation: make it private. Nothing outside `moq-tokio` calls it (only +its own unit tests), and the repo keeps things private until a consumer +needs them. If a consumer turns up, the alternative is returning the same +`Result>` as the internal `target` does once the drain line lands +(on `main` it is still `Option`, folding a refusal into "keep the +current addresses"), so empty and refused stay distinct. Removing or changing a published method is a break, so this +targets `dev`; update `doc/lib/rs` if it mentions the method. + +## Required + +- [Graceful relay drains](/quest/m1/drain/README.md) - the stricter `Connection` lands with the line +- `dev` has merged `main` after the drain line lands diff --git a/quest/m1/relay-auth-client-ca.md b/quest/m1/relay-auth-client-ca.md new file mode 100644 index 0000000000..5393c341df --- /dev/null +++ b/quest/m1/relay-auth-client-ca.md @@ -0,0 +1,37 @@ +# [S] moq-relay auth validate takes the client-CA flag + +## Goal + +On dev, no caller of `moq_relay::auth::Config` can start with `--auth-public` +rules alongside a listener TLS client CA by forgetting a check. +`Config::validate` takes the client-CA flag, `init` requires it too, and +`validate_client_ca` is gone. The `moq` CLI fails loud on an invalid auth +config instead of quietly refusing every session. + +## Plan + +- [#4364](https://github.com/moq-dev/moq/pull/4364) adds an additive + `Config::validate_client_ca(&self, client_ca: bool)` that `Relay::load` and + the CLI must each remember to call; the CLI missing the original check is + the bug it fixes. Fold it into `validate(&self, client_ca: bool)` so every + caller has to answer, and have `init` take the same answer so a caller that + skips `validate` still cannot start. `init` today only receives the + outbound auth TLS, so the listener's client-CA answer is a new input; its + shape (a bool, or the listener TLS config) is open. Prefer whatever makes + the wrong call unrepresentable. +- `spawn_server` in `rs/moq-cli/src/main.rs` maps any `auth.validate()` error + to `Auth::refuse`. That fallback is only right for a LAN-only mesh with no + auth configured, which `MoqSide::validate` permits and whose peers admit + through the cluster. Make that case explicit and let any other error stop + startup. +- Update every caller, the tests #4364 added in both `moq-cli` and + `moq-relay`, and `doc/bin/relay/auth.md` or `doc/lib/rs` wherever they name + the methods. + +Public API: breaks `moq_relay::auth::Config::validate` and `init`, removes +`validate_client_ca`, so this targets `dev`. Wire: none. + +## Required + +- #4364 merged to `main` +- `dev` has merged `main` after #4364 lands diff --git a/quest/m1/relay-iroh-opt-in.md b/quest/m1/relay-iroh-opt-in.md new file mode 100644 index 0000000000..51e2fa9b54 --- /dev/null +++ b/quest/m1/relay-iroh-opt-in.md @@ -0,0 +1,30 @@ +# [S] iroh opt-in for moq-relay + +## Goal + +moq-relay no longer builds iroh by default, and its shipped binaries, nix +package, and Docker image leave it out. iroh adds about 63 crates to the +relay: 401 dependencies with it, 338 without. + +## Plan + +Decided in planning: + +- Only the relay changes. moq-cli keeps iroh by default because the P2P + questline dials native peers over it, which mDNS's LAN discovery doesn't + replace. `moq relay` (the planned verb) keeps iroh through moq-cli's + features. +- Target `dev`. Removing a default feature from a published crate, and + flags from a shipped binary, is a published break. + +Guidance: + +- An iroh setting given to a build without the feature must be refused, not + ignored, whether it comes as a flag, an environment variable, or TOML. +- Update `doc/bin/relay/` and any example that relies on the relay's iroh + listener. Report the binary size difference in the PR. + +## Related + +- [`moq relay`](/quest/m1/moq-relay-subcommand.md) - forwards the relay's features from moq-cli's +- [P2P](/quest/m1/p2p/README.md) - why moq-cli keeps iroh diff --git a/quest/m1/relay-late-joiner-history.md b/quest/m1/relay-late-joiner-history.md new file mode 100644 index 0000000000..bd041dc43c --- /dev/null +++ b/quest/m1/relay-late-joiner-history.md @@ -0,0 +1,44 @@ +# [M] Late joiner history + +## Goal + +A second subscriber that joins a relay's track from group 0 while a first +subscriber still holds it receives every group the relay has cached, +including a finished group older than the open live one. A regression test +that joins late through a relay fails before the fix. + +## Plan + +Found downstream on moq.pro: its billing rollup tracks replay a finished +history group followed by an open live group, and a second browser opening +the Cost page on the same edge intermittently loses the older group, so +prior-period usage rows are missing. Production dashboards are affected, +not just tests. #4387 fixed the first viewer's version of this; what it +leaves is the viewer that joins later. + +What we saw: + +- With #4387 applied, moq.pro's `e2e/tests/rollup-history.spec.ts + --repeat-each 12` still fails about a quarter of runs, always on the + second viewer. The edge logs `serving group` only for the newer group on + that viewer's subscription, while the first viewer got both. +- A late-joiner variant of `rs/moq-net/tests/history_groups.rs` showed the + relay's late cursor spliced across two segments with its floor at the + newer group, so the cached older group sat below it. The segment churn + comes through `Action::Query`, `Splice`, `Park`, and `Release` in + `rs/moq-net/src/model/origin.rs`, triggered by the TRACK_INFO request that + precedes each subscription and then goes idle. +- That variant is not a faithful reproduction yet: under paused time the + origin's linger timers fire during the test's idle waits, so it also failed + for lite-03 and the IETF drafts, which the real stack does not. A + reproduction needs to keep the relay's track warm without auto-advancing + past the linger, or drive the timers explicitly. + +Start from how a newly spliced or parked segment derives its floor for a +subscriber that asked for group 0, and whether a warm copy's cached groups +below the new segment's first group stay reachable. The +[splice edge cases](/quest/m1/splice-edges.md) touch the same code. + +## Related + +- [Splice edge cases](/quest/m1/splice-edges.md) - other spliced-track cases that lose or mis-judge groups diff --git a/quest/m1/relay-memory.md b/quest/m1/relay-memory.md index a3c5f6fd82..b4d9037702 100644 --- a/quest/m1/relay-memory.md +++ b/quest/m1/relay-memory.md @@ -28,17 +28,14 @@ committed, since they need `#[doc(hidden)]` size probes on private types. Rebuild them from this description and restate the per-broadcast and per-route cost, the per-peer session bookkeeping (`announce_ids`, `held`, `watched`), and the shed threshold on a degree-5, 1 GB node, before anyone -quotes a number again. Two committed directions depend on the answer: -chat-shaped traffic (one broadcast per channel or per chatter) and -[PoP skipping](/quest/m1/pop-skipping/README.md), which triples average -degree and adds a second, more specific route per carried broadcast. +quotes a number again. Chat-shaped traffic (one broadcast per channel or +per chatter) depends on the answer. -Asking peers for announcements only while something watches was considered -and dropped: every loop-free way to forward coalesced interest through a -cyclic mesh (a hop budget, an originator set, cost-decreasing interest over -coarse claims) adds teardown churn or new wire state, for a saving nobody -has measured. Shrink the table itself instead. +On-demand announcements are now [Cluster routing](/quest/m1/cluster-routing.md)'s +plan: a relay learns only the prefixes its own clients request, which bounds +the table by demand. These numbers size that saving. ## Related +- [Cluster routing](/quest/m1/cluster-routing.md) - on-demand announcements shrink the table this measures - [Perf](/quest/m1/perf/README.md) - the hot-path work that owns the remaining per-cell cost diff --git a/quest/m1/release-profile.md b/quest/m1/release-profile.md new file mode 100644 index 0000000000..9bd60cc552 --- /dev/null +++ b/quest/m1/release-profile.md @@ -0,0 +1,51 @@ +# [S] Release profile: fat LTO, one codegen unit, stripped + +## Goal + +Every `cargo build --release` produces what we mean to ship: the relay and +CLI binaries, the Python wheel (maturin), Dart's native asset, the nix +packages, and the moq-ffi and moq-c builds all get the same size settings +from `[profile.release]` in the workspace `Cargo.toml`. Today only +`rs/moq-ffi/build.sh`, `rs/moq-c/build.sh`, and `nix/overlay.nix` export +`CARGO_PROFILE_RELEASE_LTO=thin` and one codegen unit, so the PyPI wheel +ships a 31 MiB unstripped `.so` where `build.sh` ships 24 MiB, and the +relay and CLI get no LTO at all. + +Measured on aarch64-apple-darwin, rustc 1.98.1 (2026-09-26): + +| artifact | default release, stripped | fat LTO, 1 CGU, strip | +|---|---|---| +| moq-relay | 26.1 MiB | 22.9 MiB | +| libmoq_ffi.dylib | 21.7 MiB | 20.0 MiB | +| libmoq_ffi.a | 121 MiB (unstripped) | 40.8 MiB | + +Release build time went from 5m to 9m on that machine. + +## Plan + +Decided in planning: + +- `lto = "fat"` and `codegen-units = 1`, not thin: the extra link time is paid + only by release builds, and fat LTO usually helps speed as well as size. +- `strip = "symbols"` everywhere. Released relay binaries already appear + stripped (zig's linker, unconfirmed), and the ffi cdylibs keep their + exported symbols in the dynamic table. Panic backtraces losing function + names is accepted. +- opt-level stays 3 here. A size-optimized profile for the bindings is its own + quest, gated on a benchmark. + +Guidance: + +- Delete the three `CARGO_PROFILE_RELEASE_*` exports. Move the comment about + the Go mirror's 100 MB limit next to the profile. +- `profiling` and `release-with-debug` inherit from release, so they would + inherit the strip too. Override them so they still produce symbolized + captures. `wasm-release` can then drop whatever it now inherits. +- Measure on Linux as well (x86_64 and aarch64), and put sizes and link times + for the release matrix in the PR. Check that the Go mirror's staticlibs + still fit under its limit. + +## Related + +- [Size report](/quest/m1/size-report.md) - the nightly job that shows what this changes over time +- [Bindings size profile](/quest/m1/ffi-size-profile.md) - the opt-level trade this quest leaves out diff --git a/quest/m1/release-size.md b/quest/m1/release-size.md deleted file mode 100644 index e663493098..0000000000 --- a/quest/m1/release-size.md +++ /dev/null @@ -1,51 +0,0 @@ -# [S] Release profile: one LTO setting and a nightly size report - -## Goal - -The shipped moq-ffi and moq-c artifacts already build with thin LTO and one -codegen unit, but through `CARGO_PROFILE_RELEASE_*` exports in three places -(`rs/moq-ffi/build.sh`, `rs/moq-c/build.sh`, `nix/overlay.nix`), so the -Python wheel (maturin), `moq`, `moq-relay`, and every `cargo build --release` -by a self-builder get none of it, and a plain release build measures nothing a -user receives. After this quest `[profile.release]` in the workspace -`Cargo.toml` is the one place the setting lives, every release artifact gets -it, and a nightly job reports what each moq-ffi build ships, built the way -the release builds it, so size regressions are visible instead of discovered -in an app store review. - -Measured on aarch64-apple-darwin, moq-ffi, stripped dylib: - -| build | plain release | thin LTO, 1 CGU (as shipped) | -|---|---|---| -| default (audio + video) | 14.78 MB | 14.09 MB | -| `--no-default-features` | 13.40 MB | 12.68 MB | - -The staticlib drops far more (80 MB to 30 MB for the slim build), which is -what the scripts were added for. Of the default dylib's 11.5 MiB of `.text` -before LTO: the network layer (moq_net, moq_native, noq, rustls, aws-lc, -tokio, qmux, reqwest) is about 5.4 MiB, std 1.5 MiB, moq_mux plus mp4_atom -plus hang plus the container parsers 1.2 MiB, the codecs (libopus, openh264, -symphonia, moq_audio, moq_video) 0.9 MiB, and regex 0.6 MiB, pulled by one -AV1 codec-string match in `rs/hang/src/catalog/video/av1.rs`. - -## Plan - -Move `lto` and `codegen-units = 1` into `[profile.release]` and delete the -three exports; the scripts' comments explaining the Go mirror's 100 MB limit -move with the setting. `profiling` and `release-with-debug` inherit it; check -both still produce symbolized captures. Thin LTO is the known-good starting -point. Try `lto = "fat"` and `strip = "symbols"` on top and keep each only if -the table earns it: thin LTO bought 5% on the dylib, so fat is not assumed to -buy much, and `strip` must not break the crash reports C ABI users read -(moq-c ships a symbols file if it does). Link time on the release matrix -goes in the same table as the sizes. - -Then the nightly job: a `size` recipe under `sh/` that builds both moq-ffi -configurations and `moq-c` with the release profile, prints stripped sizes -and `cargo bloat --crates -n 30` per build, and posts the result to the job -summary. `cargo bloat` reads the symbol table, so if `strip` lands the recipe -builds with `CARGO_PROFILE_RELEASE_STRIP=none` for the bloat pass and strips -a copy for the size column. Wire it into `nightly.yml` beside `Features`. The one-line AV1 regex -in hang is worth replacing with a hand parser while the bloat table is open, -if it is what keeps regex in the link (tracing-subscriber's env filter also -uses regex-automata, so check the table rather than assume). diff --git a/quest/m1/remove-live.md b/quest/m1/remove-live.md new file mode 100644 index 0000000000..99c040ec2c --- /dev/null +++ b/quest/m1/remove-live.md @@ -0,0 +1,50 @@ +# [M] Importers publish stream timestamps; the catalog clock maps them to wall + +## Goal + +The fMP4, MPEG-TS, and FLV importers in `rs/moq-mux` have no `live()`: every +importer publishes the stream's own timestamps verbatim (after PTS unwrap), +and the catalog's root `clock` is what maps them to wall time. `moq import` +(`rs/moq-cli/src/publish.rs`) stops calling it. An encoder that restarts its +timestamps ends the broadcast with an error instead of being re-anchored +forward onto the old one; turning the republish into a new epoch is the +broadcast epoch line's outcome, not this quest's. The SRT, RTMP, and HLS gateways, which reuse these +importers, get the same behavior. + +## Plan + +Decided (2026-09-28, replacing the gateway live-clock quest, which planned +the opposite: every gateway opting into `live()`): + +- Timestamps stay verbatim from the stream. Rewriting them onto an + arrival-time anchor breaks same-hop importers, which must derive every + timestamp from the input alone (the hop-aligned import quest in + [#4388](https://github.com/moq-dev/moq/pull/4388)), and hides the source's + own timeline from anything downstream. +- The catalog clock carries the mapping. An importer establishes the root + `clock` so the stream's PTS converts to wall time (the first frame is live + on arrival), rather than translating each timestamp. Open: whether the + importer sets the catalog clock from its first frame (the mapping is fixed + at construction today, `moq_mux::Clock` and `catalog::Config::with_clock`) + or the caller builds the catalog once the first PTS is known. Every track of + one input, and every rendition of one HLS import, shares that one mapping. +- An encoder restart (a PTS rewind or a signalled time-base discontinuity) is + a new epoch, not a forward re-anchor: the importer ends the broadcast with + an error, and the caller republishes, which the broadcast epoch line turns + into a fresh `@`. Until that line lands, a restart fails loud. +- Delete `live()` from `ts::Import`, `fmp4::Import`, and `flv::Import`, the + crate-private `clock::Anchor`, and `SourceMap` if nothing else uses it. + fMP4 passthrough stops rewriting `tfdt`. A published `moq-mux` API break, + so this targets `dev`. + +Tests: per importer, a source starting at a large PTS publishes that PTS and +a catalog clock that maps it to near the arrival time; a rewind ends the +broadcast with an error. Update `doc/lib/rs/moq-mux.md` (the `live()` +paragraph), `doc/bin/cli.md`, and the gateway pages under `doc/bin/`, and +replace `ts_import_publishes_on_the_broadcast_clock` in moq-cli. + +## Related + +- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - the new epoch an encoder restart becomes +- [Catalog wall clock](/quest/m1/catalog-wall-clock.md) - the PTS-to-wall conversion this relies on, at full precision +- [TS import shared shift](/quest/m1/ts-import-shared-shift.md) - the TS re-anchor shift that must stay input-derived diff --git a/quest/m1/resume-latest.md b/quest/m1/resume-latest.md new file mode 100644 index 0000000000..f594bfc566 --- /dev/null +++ b/quest/m1/resume-latest.md @@ -0,0 +1,74 @@ +# [S] A resumed group ends once the new copy is past it + +## Goal + +A reader that awaits only the in-flight group's `read_frame` never parks forever +after its source fails mid-group. Once the track fails over to a new copy, the +half-delivered group either completes from that copy or ends with an error, on +every lite version and for local and remote sources alike. The one exception +is a copy that later drops the group it is serving; that waits on +[SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md). + +Two reports, one mechanism: + +- #4392: a publisher aborts a track mid-group over a session. On lite-03/04 the + far subscriber's in-flight `read_frame` stays pending forever; on lite-05/06 + it errors. +- #4365: a publisher aborts a track mid-group and re-creates it under the same + name in one origin. The origin reader parks in `read_frame` while a direct + reader gets the abort. + +## Plan + +Why it hangs: `track_ended` in `rs/moq-net/src/model/front.rs` treats a copy +that dies after delivering as failover and splices a new copy. The resumed +`resume::Group` then waits in `track.poll_peek_group` +(`rs/moq-net/src/model/resume.rs`) on that copy, but never asks it for +anything. Only `resume::Subscriber::apply`, driven by `recv_group`, subscribes +to a new copy. On lite-03/04 the re-query accepts the track without asking the +peer (no TRACK stream), so no SUBSCRIBE goes out and the refusal never comes +back. An in-process copy declares no start, so its peek answers "not yet" +forever. The doc comment on `resume::Group` promises that a group reader alone +picks up the continuation; that promise is what breaks. + +Decided: + +- While a resumed group waits on a copy, it holds a subscription on that copy + with `start = latest`, the same shape as a fresh subscriber. A refusal ends + the group with that error. +- If the copy's latest group is past the resumed group, the group ends with an + error at once. A same-name re-create is a continuation, so group 2 of the old + track ends and group 3 of the new one arrives. No special case for local + broadcasts. +- If the resumed group is the copy's latest, it reads the rest from that + subscription. A copy that later drops it says so with SUBSCRIBE_DROP once + [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) lands; until then that case + waits as today. +- No FETCH, not even where the version supports it. Fetching older groups from + the new copy may come later as its own quest. +- Don't abort the old group eagerly on failover instead: long-lived groups + like a catalog must survive a failover. + +`poll_finished` needs the same subscription as `poll_current`. Drop the +subscription once the wait resolves. + +A prototype of the subscription half (about 20 lines in `resume.rs`) turned +all eight #4392 cases into errors with every moq-net test passing. + +Regression tests, on a paused clock (`#[tokio::test(start_paused = true)]`): + +- The #4392 repro as `rs/moq-net/tests/track_abort_over_session.rs`: abort + mid-group over one and two hops on lite-03 through lite-06; the in-flight + read errors. +- The #4365 repro: abort mid-group 2, re-create at group 3 through the origin; + group 2 ends with an error and group 3 arrives. + +## Closes + +- [#4392](https://github.com/moq-dev/moq/issues/4392) - close this issue when the quest finishes +- [#4365](https://github.com/moq-dev/moq/issues/4365) - close this issue when the quest finishes + +## Related + +- [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - tells a resumed latest group when the new copy dropped it +- [Splice edge cases](/quest/m1/splice-edges.md) - the same splice code and test harness diff --git a/quest/m1/route-cost.md b/quest/m1/route-cost.md deleted file mode 100644 index 9268d3d06f..0000000000 --- a/quest/m1/route-cost.md +++ /dev/null @@ -1,27 +0,0 @@ -# [S] Route cost in the JS origin - -## Goal - -The `@moq/net` origin serves a path through the best route it knows, ranked by -cost and then hop count the way Rust's origin does, instead of the newest -announce. The lite-06 route cost that arrives on every announce is read rather -than dropped. - -## Plan - -`announce.ts` already decodes `Cost { warm, cold }` and `hop.ts` already -carries the hop chain; neither reaches `OriginState.remote`, which keeps -providers newest-first. Carry both on the provider, rank with the same order -as `route_order` in `rs/moq-net/src/model/origin.rs` (cost, chain length, a -deterministic tiebreak), including `Cost::UNKNOWN` for an announce that -carries no cost (free to reach, cold path at the ceiling, so hop count decides -as it did before route cost existed), and re-pick when the chosen route is -retracted. - -Tests in `js/net` with the mock transport pair: two sessions announcing the -same path at different costs, the cheaper one serves, its retraction moves -the consumer to the other. - -## Related - -- [P2P](/quest/m1/p2p/README.md) - the watcher-side route pick that line needs diff --git a/quest/m1/rs2ts/README.md b/quest/m1/rs2ts/README.md new file mode 100644 index 0000000000..a075224ac0 --- /dev/null +++ b/quest/m1/rs2ts/README.md @@ -0,0 +1,78 @@ +# Generated @moq/net + +## Goal + +moq-net is the single implementation of the MoQ protocol and model layer. +The browser runs it as TypeScript generated from the Rust source, retiring +js/net's hand-written equivalent with no regression in bundle size, CPU, or +usability. Lite comes first, IETF after. Transport glue (the WebTransport and +WebSocket pumps, timers) stays hand-written TypeScript. + +## Plan + +Decided in planning (2026-09-27), with the spike data in +: + +- Generated TypeScript, not WASM. Today's `moq-wasm` is 527 KB gzip against + js/net's 84 KB, and loses on CPU to async wasm-bindgen glue (~600 ns per + async call against 26 ns in JS). A hand-carved sans-IO lite core was 6 KB + gzip and 1.7-9x faster than js/net, but a plain-JS port of the same + synchronous decoder was faster still: the win is the sans-IO shape, not + WASM. The model layer is shared too, and every call on a model handle would + cross the WASM boundary, so generated TS is the path. +- The translator is `rs/rs2ts`, built on Charon (Rust MIR restructured into + LLBC). Charon's `--precise-drops` gives exact drop points, which the + close-on-last-drop handles depend on, and `--start-from` extracts a subset. + rust-js was evaluated and rejected as a base: no Drop, no generic traits, + JS only, all-or-nothing extraction, 32-bit `usize`. Its MIT oxc printer is + worth borrowing for formatting and source maps. +- moq-net itself becomes the sans-IO core: bytes and timestamps in, events + and bytes out, no runtime. The async helper methods move behind an `async` + cargo feature; rs2ts reads the crate without it and JS reimplements the + helpers with Promises. No second crate. +- Varints stay 62-bit on the wire; the spec is not bounded to 2^53. Rust's + `VarInt` newtype carries Encode/Decode and JS gets a matching `VarInt` type + with checked conversion to and from `number`. +- The generated TypeScript is committed and a CI lane regenerates it and + fails on drift, so JS contributors and npm publishing never need the + nightly toolchain Charon pins. It lives inside js/net and `@moq/net` stays + the package. +- The `@moq/net` API may change (disposable handles, `VarInt`) as long as it + is no worse to use; watch, publish, hang, and the demos update in the same + change. +- Parity: `just test interop --all`, plus moq-net's own tests translated with + the code once they run on a mock clock instead of tokio. +- The line lands on `dev`: the Rust refactors break moq-net's published API, + and the translator and generated code build on them. Only the additive + [JS VarInt](/quest/m1/rs2ts/js-varint.md) lands on `main`. +- Hand-written js/net fixes keep landing until the generated path replaces + them; it is months out. + +This README's own work is the no-downgrade report once generated lite ships: +bundle size, per-frame CPU, and first-frame latency against the hand-written +js/net it replaces, measured with the [browser benchmarks](/quest/m1/browser-benchmarks.md). + +## Required + +- [VarInt codec](/quest/m1/rs2ts/varint-codec.md) - moq-net encodes through a `VarInt` newtype and a concrete slice-based codec, not generic traits on primitives +- [JS VarInt](/quest/m1/rs2ts/js-varint.md) - js/net has a 62-bit `VarInt` type with checked `number` conversion and no BigInt on the hot path +- [rs2ts](/quest/m1/rs2ts/translator.md) - a Charon-based translator emits readable TypeScript for moq-net's lite codec, committed and checked for drift in CI +- [Sans-IO moq-net](/quest/m1/rs2ts/sans-io/README.md) - moq-net builds and runs without a runtime; async helpers sit behind an `async` feature +- [Mock-clock tests](/quest/m1/rs2ts/mock-clock.md) - moq-net's tests run on the sans-IO clock instead of tokio, so they translate with the code +- [Generated lite](/quest/m1/rs2ts/lite.md) - @moq/net's lite session and model layer are generated from moq-net +- [Generated IETF](/quest/m1/rs2ts/ietf.md) - @moq/net's moq-transport session is generated too +- [Remove moq-wasm](/quest/m1/rs2ts/remove-wasm.md) - the WASM experiment is deleted once generated lite ships + +## Closes + +- [#2907](https://github.com/moq-dev/moq/issues/2907) - close this issue when the quest finishes +- [#2822](https://github.com/moq-dev/moq/issues/2822) - close this issue when the quest finishes +- [#2835](https://github.com/moq-dev/moq/issues/2835) - close this issue when the quest finishes + +## Required + +- [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the harness the no-downgrade report uses + +## Related + +- [#2850](/quest/m1/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - the same synchronous decode shape, in hand-written js/net today diff --git a/quest/m1/rs2ts/ietf.md b/quest/m1/rs2ts/ietf.md new file mode 100644 index 0000000000..7c6ee4fea8 --- /dev/null +++ b/quest/m1/rs2ts/ietf.md @@ -0,0 +1,20 @@ +# [L] Generated IETF + +## Goal + +@moq/net's moq-transport session is generated from moq-net like lite, and +the hand-written js/net IETF code (about 8.7k lines) is deleted, with +`just test interop --all` passing. + +## Plan + +Values above 2^53 are legal on the IETF wire (request ids, track aliases); +they stay exact as `VarInt` and only fail where code converts them to +`number`. + +Public API: breaks `@moq/net`; retargets to `dev`. Wire: none. + +## Required + +- [Generated lite](/quest/m1/rs2ts/lite.md) - the pipeline this reuses +- [Sans-IO IETF session](/quest/m1/rs2ts/sans-io/ietf.md) - the session shape it translates diff --git a/quest/m1/rs2ts/js-varint.md b/quest/m1/rs2ts/js-varint.md new file mode 100644 index 0000000000..0c17bc4432 --- /dev/null +++ b/quest/m1/rs2ts/js-varint.md @@ -0,0 +1,27 @@ +# [S] JS VarInt + +## Goal + +js/net has a `VarInt` type that holds the full 62-bit range, converts to and +from `number` with a loud error outside the safe range, and encodes and +decodes without BigInt on the hot path. It is the TypeScript type rs2ts maps +Rust's `VarInt` to. + +## Plan + +Measured in node 24 for an 8-byte encode plus decode: `number` written as two +`u32` halves 2.4 ns, a `{hi, lo}` pair 4.8 ns, `bigint` 10 ns, and js/net +today (BigInt on the wire, then `Number()`) 32 ns. The leading-ones encoder +also converts every value to BigInt, even small ones. + +Guidance: + +- Store two `u32` halves; offer `fromNumber` and `toNumber` (throwing above + 2^53 or on a negative or fractional input), `fromBigInt` and `toBigInt`, + and comparison and increment methods so sequence logic never converts. +- Move js/net's varint reading and writing onto it, dropping the BigInt + round trip for QUIC and leading-ones varints. +- Unit-test the boundaries (2^30, 2^53, 2^62 - 1) against Rust's encoder in + `just test interop`. + +Public API: additive to `@moq/net`; lands on `main`. Wire: none. diff --git a/quest/m1/rs2ts/lite.md b/quest/m1/rs2ts/lite.md new file mode 100644 index 0000000000..256dd08783 --- /dev/null +++ b/quest/m1/rs2ts/lite.md @@ -0,0 +1,32 @@ +# [XL] Generated lite + +## Goal + +@moq/net's lite session and model layer are generated from moq-net by rs2ts, +and the hand-written TypeScript they replace is deleted. Hand-written +TypeScript remains only for the transport pumps, timers, and the Promise +helpers over the poll API. The translated moq-net tests and +`just test interop --all` pass, and bundle size, per-frame CPU, and +first-frame latency are no worse than the hand-written js/net. + +## Plan + +- The `@moq/net` API may change where the generated shape is no worse to + use: disposable handles (`using`), `VarInt` for sequences and ids. Update + watch, publish, hang, room, and the demos in the same change, and the + `doc/` pages for anything user-facing. +- A forgotten `drop()` leaves a track open forever: add a debug-only + `FinalizationRegistry` that reports handles collected without one, and + runtime guards against double drop and use after drop. +- Size budget: js/net's `lite/*` is 15 KB gzip today; keep generated output + near it. Watch for std shims and fmt/tracing pulling in weight. + +Public API: breaks `@moq/net`; retargets to `dev`. Wire: none. + +## Required + +- [rs2ts](/quest/m1/rs2ts/translator.md) - the translator +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the session shape it translates +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - the model shape it translates +- [The async feature](/quest/m1/rs2ts/sans-io/async-feature.md) - rs2ts reads moq-net without it +- [Mock-clock tests](/quest/m1/rs2ts/mock-clock.md) - the tests that prove parity diff --git a/quest/m1/rs2ts/mock-clock.md b/quest/m1/rs2ts/mock-clock.md new file mode 100644 index 0000000000..3f1ea344c9 --- /dev/null +++ b/quest/m1/rs2ts/mock-clock.md @@ -0,0 +1,26 @@ +# [L] Mock-clock tests + +## Goal + +moq-net's tests run on the sans-IO clock instead of tokio, so they need no +runtime and rs2ts translates them alongside the code. The generated +TypeScript runs the same tests under bun. + +## Plan + +tokio is in moq-net's tests only for paused, advanceable time +(`start_paused`, `advance`): 591 `tokio::test`s on dev, 86 of them paused. +Drive them from the model's injectable clock and a small synchronous +executor instead. + +Guidance: + +- Port mechanically where possible; keep each test's assertions unchanged. +- Tests that exercise the `async` helpers stay behind that feature and are + not translated. + +Public API: none. Wire: none. + +## Required + +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - supplies the clock seam diff --git a/quest/m1/rs2ts/remove-wasm.md b/quest/m1/rs2ts/remove-wasm.md new file mode 100644 index 0000000000..56a7f6b490 --- /dev/null +++ b/quest/m1/rs2ts/remove-wasm.md @@ -0,0 +1,19 @@ +# [S] Remove moq-wasm + +## Goal + +`rs/moq-wasm`, `js/wasm`, `just wasm`, and the WASM path in `test/wasm` are +deleted: the browser runs moq-net as generated TypeScript instead. + +## Plan + +Decided in planning: keep the experiment until generated lite ships, then +delete it rather than polish it. Remove its entries from the size report, +the justfiles, the wasm clippy lane (keep `moq-net` and `moq-mux` there if +anything still targets wasm32), and the docs. + +Public API: removes the unpublished `@moq/wasm` package. Wire: none. + +## Required + +- [Generated lite](/quest/m1/rs2ts/lite.md) - the replacement diff --git a/quest/m1/rs2ts/sans-io/README.md b/quest/m1/rs2ts/sans-io/README.md new file mode 100644 index 0000000000..03a4cfb82b --- /dev/null +++ b/quest/m1/rs2ts/sans-io/README.md @@ -0,0 +1,23 @@ +# Sans-IO moq-net + +## Goal + +moq-net builds and runs with no async runtime: bytes and timestamps go in, +events and bytes come out. The async helper methods sit behind an `async` +cargo feature, and a CI lane builds and tests the crate without it. + +## Plan + +Decided in planning: moq-net itself is the core, not a second crate. JS +reimplements the async helpers natively with Promises, so the translator +reads the crate without the `async` feature. Split by layer so each lands on +`dev` independently. + +The line has no work of its own beyond its children. + +## Required + +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the lite session is driven by bytes, stream events, and `tick(now)` +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - origin, broadcast, track, and group handles run without a runtime, with time supplied by the caller +- [The async feature](/quest/m1/rs2ts/sans-io/async-feature.md) - the async helpers sit behind an `async` feature and a CI lane builds and tests moq-net without it +- [Sans-IO IETF session](/quest/m1/rs2ts/sans-io/ietf.md) - the moq-transport session is driven the same way as lite diff --git a/quest/m1/rs2ts/sans-io/async-feature.md b/quest/m1/rs2ts/sans-io/async-feature.md new file mode 100644 index 0000000000..f7b7e35046 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/async-feature.md @@ -0,0 +1,27 @@ +# [S] moq-net's async helpers sit behind a feature + +## Goal + +moq-net's async helper methods sit behind an `async` cargo feature, and a CI +lane builds and tests the crate without it, so rs2ts reads the no-runtime +crate that [Generated lite](/quest/m1/rs2ts/lite.md) translates. + +## Plan + +- The feature is on by default, so Rust callers see no change. JS + reimplements the helpers with Promises over the poll API. +- Generated lite needs the lite session and the model without the feature, + not IETF. Until the [Sans-IO IETF session](/quest/m1/rs2ts/sans-io/ietf.md) + lands, the IETF session can sit behind the feature too; that quest then + moves it out. +- The lane runs at least the tests that do not exercise the helpers; tests + that do stay behind the feature. + +Public API: moq-net's async helpers move behind a default feature, so a +`default-features = false` caller loses them; lands on `dev` with the line. +Wire: none. + +## Required + +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - the session builds without a runtime +- [Sans-IO model](/quest/m1/rs2ts/sans-io/model.md) - the model builds without a runtime diff --git a/quest/m1/rs2ts/sans-io/ietf.md b/quest/m1/rs2ts/sans-io/ietf.md new file mode 100644 index 0000000000..542c1965a1 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/ietf.md @@ -0,0 +1,21 @@ +# [L] Sans-IO IETF session + +## Goal + +The moq-transport session is driven like the [sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md): +bytes, stream events, and `tick(now)` in, bytes and model events out. + +## Plan + +Follow whatever shape the lite session settles on. The IETF code is the +largest module (about 12.7k non-test lines) and today compiles part of itself +twice (for `Session` and `ControlStreamAdapter`); collapse that while +here. +If the [async feature](/quest/m1/rs2ts/sans-io/async-feature.md) landed +first with the IETF session behind it, move the session out. + +Public API: breaks moq-net's IETF session API; retargets to `dev`. Wire: none. + +## Required + +- [Sans-IO lite session](/quest/m1/rs2ts/sans-io/lite.md) - sets the driver shape diff --git a/quest/m1/rs2ts/sans-io/lite.md b/quest/m1/rs2ts/sans-io/lite.md new file mode 100644 index 0000000000..51e426c403 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/lite.md @@ -0,0 +1,26 @@ +# [L] Sans-IO lite session + +## Goal + +The lite session is a state machine fed bytes, stream open and close events, +and `tick(now)`; it returns bytes to write and events for the model. No +`web_transport_trait` stream types, no timers of its own, no async outside +the `async` feature. + +## Plan + +A hand-carved spike (lite-06 subscriber: SETUP, ANNOUNCE, SUBSCRIBE, group +streams, deadlines via `tick`) came to about 600 lines with frames surfaced +as `[offset, len]` ranges into the caller's chunk, so payload bytes are never +copied by the core. Decode every complete frame already buffered in one pass; +awaiting per frame is where js/net loses 2.5-3.5 µs per frame. + +Guidance: + +- Keep the session's existing behavior and wire exactly; the interop suite is + the check. +- Stream handles, write backpressure, and close codes become explicit events + or return values the driver acts on. +- Keep maps as Vec slabs where the key space is small. + +Public API: breaks moq-net's session API; retargets to `dev`. Wire: none. diff --git a/quest/m1/rs2ts/sans-io/model.md b/quest/m1/rs2ts/sans-io/model.md new file mode 100644 index 0000000000..9060397847 --- /dev/null +++ b/quest/m1/rs2ts/sans-io/model.md @@ -0,0 +1,18 @@ +# [L] Sans-IO model + +## Goal + +The origin, broadcast, track, group, and frame producers and consumers run +without an async runtime. Their waiting is poll-based on kio, and anything +time-based reads a clock the caller supplies, so the model translates to +TypeScript and its tests can run on a mock clock. + +## Plan + +The model is already poll-based on kio waiters; what remains is every place +that reaches a runtime or wall clock directly (`runtime::Deadline`, the cache +pool's expiry, stats timers). Route time through one injectable clock, the +seam the [mock-clock tests](/quest/m1/rs2ts/mock-clock.md) use. + +Public API: may break moq-net's model constructors; retargets to `dev`. +Wire: none. diff --git a/quest/m1/rs2ts/translator.md b/quest/m1/rs2ts/translator.md new file mode 100644 index 0000000000..8459752e54 --- /dev/null +++ b/quest/m1/rs2ts/translator.md @@ -0,0 +1,59 @@ +# [L] rs2ts + +## Goal + +`rs/rs2ts` translates moq-net's lite codec into readable TypeScript inside +js/net. The output is committed, a CI lane regenerates it and fails on +drift, and the generated codec passes `just test interop --all` in place of +the hand-written one. + +## Plan + +A prototype exists in the planning spike: about 1,800 lines on `charon_lib` +plus a 200-line runtime shim. It turned a sample crate into TypeScript that +passed behavioral tests, including close-on-last-drop, and ran on moq-net's +`coding` and lite message modules via `--start-from` (about 7,000 lines of +output, 500 untranslated calls, mostly std, tracing, and atomics shims). + +Mapping decided in planning: + +- Structs become classes, enums discriminated unions, traits interfaces. + `Option` is `T | undefined`, `&[u8]` a `Uint8Array` view with no copy. + That collapses a nested `Option`, whose states the source relies on: + `model/track.rs::first_start` returns `Option>` to tell + "no successor" from an unstamped one, and `reach` behaves differently for + each. Recommendation: the subset lint rejects nested `Option`, and the source + names those states with an enum; a tagged TypeScript form for the inner + `Option` is the alternative. Either way nested states never merge silently. +- `Drop` becomes an explicit `drop()` at each MIR drop point, exposed as + `[Symbol.dispose]`; `Arc`/`Rc` of a type with drop glue become an explicit + refcount. JS is single-threaded, so `Mutex` and atomics become plain + access. +- Rust `VarInt` maps to the [JS VarInt](/quest/m1/rs2ts/js-varint.md) type. + Integers up to 32 bits and `usize` map to `number` with checked arithmetic + that throws on overflow; never wrap silently. A `u64` or `i64` never maps + to a lossy `number`: the model accepts `u64::MAX` (e.g. + `model/subscription.rs`), so each one either becomes `VarInt` or an + `Option` in the source, or maps to a full-width 64-bit TypeScript type. + +Guidance: + +- Pin Charon and its nightly in the nix shell for the regeneration lane only. +- Borrow rust-js's MIT oxc printer for formatting and source maps. +- Readability pass: inline single-use temporaries and keep source branch + order, so a reviewer can read a generated diff. +- Add a lint on moq-net (clippy or dylint) for the accepted subset: no + `unsafe`, no `async` outside the `async` feature, no nested `Option`, no `u64` bit operations, + no trait impls on foreign or primitive types, no `&mut` out-params to + scalar or `Option` locals. Translator gaps become compile errors, not + runtime `todo()`s. Document the subset in `rs/rs2ts/README.md`. +- Charon stalled for 40+ minutes on the whole crate; extract only the modules + being generated. + +Public API: none (internal tool). Lands on `dev` with the codec it +translates. Wire: none. + +## Required + +- [VarInt codec](/quest/m1/rs2ts/varint-codec.md) - the codec shape the translator targets +- [JS VarInt](/quest/m1/rs2ts/js-varint.md) - the TypeScript type `VarInt` maps to diff --git a/quest/m1/rs2ts/varint-codec.md b/quest/m1/rs2ts/varint-codec.md new file mode 100644 index 0000000000..39557cb31b --- /dev/null +++ b/quest/m1/rs2ts/varint-codec.md @@ -0,0 +1,31 @@ +# [M] VarInt codec + +## Goal + +Every varint moq-net puts on the wire is a `VarInt`, and `VarInt` is the only +integer type with Encode/Decode. Messages encode and decode through a +concrete, slice-based codec instead of generic traits implemented on `u64`, +`usize`, `bool`, `String`, `Option`, and `Vec`. + +## Plan + +Two reasons, one refactor. Not every `u64` is a valid varint, so the type +should say which fields are. And the rs2ts translator cannot map generic +traits on primitives without dictionary passing, its most expensive feature; +the same generics are most of why moq-net's lite codec built to 47 KB gzip in +WASM against 6 KB for a hand-carved one. + +Guidance: + +- Keep the 62-bit range; the wire is not bounded to 2^53. +- Message types keep a local trait; the primitives become inherent methods + on concrete reader and writer types (`varint`, `string`, `bytes`, ...). + Make the version a concrete type rather than a generic `V` where possible. +- Avoid bit operations on `u64` in the codec: write the 8-byte form as two + `u32` halves, so the generated TypeScript never needs 64-bit bitwise math. +- `Parameters` becomes Vec-backed, and decode paths stop branching on + `tracing::enabled!` (log after decoding instead). +- Benchmark the codec before and after (Criterion); it is on every message. + +Public API: breaks moq-net's `coding` module (Encode/Decode on primitives +go away), so this retargets to `dev`. Wire: none. diff --git a/quest/m1/rtmp-tls-only.md b/quest/m1/rtmp-tls-only.md new file mode 100644 index 0000000000..e281f2021a --- /dev/null +++ b/quest/m1/rtmp-tls-only.md @@ -0,0 +1,32 @@ +# [XS] RTMP listener can refuse plaintext + +## Goal + +An operator who configures TLS on the RTMP ingest listener can require it. +Today a TLS-configured listener sniffs the first byte and serves any +non-ClientHello connection as plaintext `rtmp://` on the same port +(`rs/moq-rtmp/src/listen.rs`), so setting `tls` silently keeps accepting +unencrypted stream keys. The sniffing behavior stays available; it is just no +longer the only choice. + +## Plan + +Decided by the maintainer during the merged-PR audit: add a mode rather than +drop the sniffing. #3964 shipped mixed mode after a bot review, not a human +one, and nobody chose it for operators who want TLS only. + +- Model it so a TLS-only listener without a TLS config cannot be expressed, + for example an enum carrying the `ServerConfig` (plaintext, TLS required, + TLS or plaintext) instead of a bool beside the `Option`. Keep today's + behavior as the default for an existing `tls` config unless that reads + wrong once written; say which in the PR. +- A plaintext connection to a TLS-only listener is refused at the first byte, + with a log line naming the peer, not left to time out. +- Thread the choice through the relay and gateway configs that build this + listener, and update `doc/bin/rtmp.md` and any relay config docs that + describe RTMPS. +- Test both modes: a plaintext client is refused by TLS-only and served by + mixed. + +Public API: additive on moq-rtmp's listen config if the default holds; a +published break (dev) if the field's type changes. Wire: none. diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md index 39ad6edba2..499c51e678 100644 --- a/quest/m1/session-close.md +++ b/quest/m1/session-close.md @@ -22,3 +22,11 @@ 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. + +The interop runner is the consumer that found this +([#4209](https://github.com/moq-dev/moq/pull/4209)): after a successful +publish it calls `abort`, so the relay never sees `PUBLISH_NAMESPACE_DONE` and +the next run is told the namespace is already published. Switch it to the +graceful `close()` once that exists. If the calling code lives outside this +repository (moq-interop-runner), that change is a PR there and needs the +maintainer's approval before posting. diff --git a/quest/m1/session-death.md b/quest/m1/session-death.md new file mode 100644 index 0000000000..0eec5aa84a --- /dev/null +++ b/quest/m1/session-death.md @@ -0,0 +1,47 @@ +# [S] Session death parity + +## Goal + +Rust and JS end a session's tracks and groups the same way: + +- A session closed locally, on purpose, ends the tracks it was receiving + cleanly in both languages. Groups still in flight end as they do today. +- A session that dies (peer close, transport failure) ends its tracks and + its group readers with the session's error, carrying the peer's code, + never the raw transport error, `Dropped`, or `Cancel`. + +## Plan + +https://github.com/moq-dev/moq/pull/4120 made a dying session end its tracks +with the session's error in both languages, and left two gaps. + +Since then #4351 and #4378 changed Rust abort semantics: an abort keeps the +finished groups and drops only the open ones, so readers get what finished +and then the abort, or a clean end once the declared end settled. #4385 (open) +mirrors that in JS. Build the clean end below on those semantics. + +Decided by the maintainer: + +- **A local close is a close, not an error.** JS already ends tracks cleanly + on `close()`. Rust has no clean end short of a finished track, so #4120 + ends them with the close error (previously `Cancel` or `Dropped`). Rust + needs a clean end at the current edge that is not a declared end: readers + get what was delivered and then `None`. Keep #4120's rule that an abort + before a declared end settles still wins, and #4116's clean end for a + dropped producer after `finish_at`; the new path must not mask either. + Whether this is a new `track::Producer` method or a driver-internal path + is an API call, so keep it private unless a consumer needs it. +- **JS group readers see the session's error.** Tracks go through + `sessionCause` in `js/net/src/error.ts`, but `runGroup` in the lite and + IETF subscribers closes a group with the raw error. Route it the same way. + +Extend the existing session-death tests (Rust +`a_session_death_ends_the_track_with_its_error`, the JS lite and IETF +integration cases) with a local close and with a group reader, on lite and +IETF. Behavior change, no signature change on either side unless the Rust +clean end needs a public method. + +## Related + +- [Track tail hardening](/quest/m1/track-tail-hardening.md) - the tail rules this must not mask +- [Graceful session close](/quest/m1/session-close.md) - what a local close sends the peer diff --git a/quest/m1/setup-token.md b/quest/m1/setup-token.md deleted file mode 100644 index f90d4f5d97..0000000000 --- a/quest/m1/setup-token.md +++ /dev/null @@ -1,67 +0,0 @@ -# [M] The SETUP AUTHORIZATION TOKEN option reaches the verifier - -## Goal - -A moq-transport peer's SETUP `AUTHORIZATION TOKEN` option (key `0x03`, -draft-14 through the newest supported draft) is decoded by `rs/moq-net` into -a token type and value, exposed on the accepted handshake so an app can run -its own verifier, and forwarded by the relay in `moq_auth::Request` as -`token`, so an auth server can verify it. Today the option is stored as -opaque bytes and ignored. - -Boundaries: SETUP only. The same parameter on SUBSCRIBE, REQUEST_UPDATE, and -every other request is [Request tokens](/quest/m1/auth/request-token.md): a -fallback that authorizes only that request. No client configuration: [Token in -band](/quest/m1/auth/token-in-band.md) owns presenting one. - -## Plan - -- `moq_net::setup::Token { kind: u64, value: Vec }`, `kind` being the - wire Token Type: `Token::OUT_OF_BAND` is `0x0` and `Token::CAT` is the - `0x01` c4m-01 registers. `setup` becomes a public module exporting only - `Token`; the SETUP wire types stay crate-private. Decode the draft-21 - section 8.9 structure: Alias Type `USE_VALUE` yields the token; `REGISTER` - is treated as `USE_VALUE` because we advertise no - `MAX_AUTH_TOKEN_CACHE_SIZE` (the default of 0 makes that the draft's own - rule), so no alias state exists; `DELETE` or `USE_ALIAS` in SETUP closes - with `PROTOCOL_VIOLATION`; an undecodable structure closes with - `KEY_VALUE_FORMATTING_ERROR`. More than one token in SETUP is refused: one - credential per connection is the shape the contract has. -- Encode side: `Parameters` learns to write a `USE_VALUE` token, so [Token - in band](/quest/m1/auth/token-in-band.md) and - [Present](/quest/m2/cat/present.md) have nothing to add on the wire. -- `moq_net::server::Handshake::token() -> Option<&setup::Token>` beside - `path()` and `role()`, carried through the `Legacy` (draft-14 to 16) and - `PeerSetup` (draft-17+) paths; `moq_tokio::server::Request::token()` - forwards it; lite sessions return `None`. -- `moq_auth::Request.token: Option`, - serialized with `serde_with` base64 like the rest of the request. The relay - fills it in `request_for` in `rs/moq-relay/src/auth.rs`, beside the URL - query it already forwards. `moq auth serve` treats kind `0x0` as the JWT - the deployment negotiated out of band, verified exactly like `?jwt=`, and - refuses any other kind naming the code until - [Verify](/quest/m2/cat/verify.md) teaches it `0x01`. A request carrying - both a SETUP token and a `jwt` query is refused naming both. -- `js/net/src/ietf/parameters.ts` mirrors the decode and encode rules. No JS - accept-side API: nothing in `js/net` authorizes an IETF session. -- Docs: `doc/concept` on the IETF binding gains the option; - `doc/lib/rs/moq-auth.md` gains the request field; `doc/bin/relay/auth.md` - says the relay forwards the option and `moq auth serve` verifies a type-0 - token like `?jwt=`. -- Tests: decode and encode round trips for every alias type on every draft - in Rust and JS; `DELETE`/`USE_ALIAS` in SETUP close the session; `REGISTER` - is accepted as a value; two tokens in SETUP are refused; the handshake - exposes the bytes on one legacy and one draft-17+ session and a lite - session reports none; the relay forwards the bytes to a wiremock auth - server byte for byte; `moq auth serve` admits a type-0 JWT, refuses an - unknown kind, and refuses a token plus `?jwt=`. - -Public API: additive on `moq-net`, `moq-tokio`, `moq-auth`, and `js/net`. -Wire: none new; the option already exists in every supported draft. - -## Related - -- [Token in band](/quest/m1/auth/token-in-band.md) - writes the same option - from the client side with the shared `setup::Token` -- [Common Access Tokens](/quest/m2/cat/README.md) - verifies and presents a - CAT through this option diff --git a/quest/m1/shaper-virtual-time.md b/quest/m1/shaper-virtual-time.md new file mode 100644 index 0000000000..3fea8a4fb4 --- /dev/null +++ b/quest/m1/shaper-virtual-time.md @@ -0,0 +1,18 @@ +# [S] moq-shaper tests on virtual time + +## Goal + +The `moq-shaper` unit tests pass under any host load: their verdicts come +from the seeded decisions, not from how fast the kernel delivers datagrams. +`reorder_and_jitter_overtake` and `the_seed_reproduces_the_losses` saw every +datagram lost during a slowed full-workspace run, because `round_trip` stops +at the first 200 ms of silence on a real socket. + +## Plan + +- Drive the tests on paused tokio time (or an injected clock) so delay, + jitter, and the read deadline advance deterministically. +- Watch out: paused time auto-advances while the runtime idles, which can + fire a timer before a real UDP datagram lands. If the real sockets fight + the paused clock, separate the scheduling logic from the socket I/O and test + it directly, keeping one socket smoke test with a generous deadline. diff --git a/quest/m1/signed-priority.md b/quest/m1/signed-priority.md new file mode 100644 index 0000000000..756dd4bf0d --- /dev/null +++ b/quest/m1/signed-priority.md @@ -0,0 +1,53 @@ +# [L] Signed priority + +## Goal + +Every priority in the API is an `i8`, higher first, with 0 as the unset +midpoint: `track::Info`, `Subscription`, and `group::Fetch` in Rust, their +JS counterparts, moq-ffi, moq-c, and every wrapper. Nobody has to know that +127 is the middle of a `u8`, the default the moxygen line ships. The wire +stays a byte. + +## Plan + +Decided with the maintainer: + +- moq-lite carries `p + 128` (flip the top bit). The mapping is one-to-one + and keeps order, so a given byte means what it means today; only the API + number changes. The default becomes byte 128. +- IETF carries `127 - p`. Both mappings are one-to-one over the whole `i8` + range, with no saturation. An unset priority goes out as IETF 127, not the + draft's usual 128, and an absent IETF priority decodes to 0, the unset + default. Pin both ends in tests. +- Invariant, kept from the [moxygen line](/quest/m1/moxygen/README.md)'s + default-priority quest (#4273): one urgency on both wires, so the IETF byte + is always `255 -` the lite byte. Mapping IETF as `128 - p` to hit the + draft's 128 would break it, which is why the default is one step off the + draft there. +- hang's built-in priorities move above 0, so hang media outranks a track that + never set one. Something like catalog 40, text 30, audio 20, video 10; the + spacing is the implementer's call. Rust and JS keep matching values. +- A zeroed moq-c `moq_track_info` then means the default, which retires the + need for a `priority_present` flag. + +Changing published `u8` fields to `i8` is an API break in every language, so +this lands on `dev`. Look for anything that does arithmetic on priority +(the lite send queue, JS send-order packing, the bandwidth allocator, the +relay's max-of-subscribers) and keep its ordering, not just its type. + +moq-archive's `Info::priority` follows. Its version-1 `.info` stores the +`u8`, so existing recordings must keep their meaning: store the moq-lite byte +(`p + 128`) or bump the format version, never reinterpret silently. +`doc/concept/moq-lite.md` (the 0..255 knob) and `doc/concept/standard.md` +(IETF 128 maps to 127) move to the new range and mapping. + +Report the wire impact in the PR: none in format, but the default byte moves +again, from 127 to 128 on moq-lite and from 128 to 127 on IETF. + +## Required + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - ships the 127 default and the one-urgency invariant this re-maps + +## Related + +- [Scope track priority](/quest/m1/track-priority-scope.md) - which streams a priority competes with, not its type diff --git a/quest/m1/size-report.md b/quest/m1/size-report.md new file mode 100644 index 0000000000..8b1140915c --- /dev/null +++ b/quest/m1/size-report.md @@ -0,0 +1,60 @@ +# [M] Nightly size report + +## Goal + +A nightly job reports the size of what we ship, built the way the release +builds it, and alerts when any of it grows. Size regressions show +up within a day instead of in an app store review or a user's bundle +analyzer. + +## Plan + +Decided in planning: + +- Nightly, not per PR. Size moves only on dependency bumps, feature flips, or + profile changes, so a per-PR comment would say "no change" almost every + time. +- No hard budgets in CI. The job keeps a rolling history and fails when an + artifact grows past a threshold against the previous run (start at 5%) or + against a longer window (for example 10% over 30 days). The window catches + slow drift, which night-to-night checks miss. The existing `alert.yml` path + turns the failure into a Discord alert. Add the workflow to its list if + needed. +- Scope: + - moq-ffi, default and `--no-default-features`, as cdylib and staticlib + - moq-c, moq-relay, and moq-cli + - the moq-wasm module, raw, gzip, and brotli + - the consumer cost of the JS entries: `@moq/net`, `@moq/watch/element` + with and without `/ui`, and `@moq/publish/element`. Measure them the way + a consumer sees them: bundled and minified from the built packages, + first-load chunks only, gzip and brotli. + - the packaged outputs nightly already builds. `nightly.yml` runs the + Python, Kotlin, and Swift release builds, so read their wheel, AAR, and + xcframework sizes instead of rebuilding them. +- Out of scope: Docker images, and per-target release assets nightly doesn't + build. The Docker quest measures its images once. Add others only if one of + them regresses unnoticed. +- The job summary also carries `cargo bloat --crates` for the ffi build and a + metafile breakdown for the publish element, so the cause of a jump is + visible without a local rebuild. `cargo bloat` needs symbols, so build that + pass with `CARGO_PROFILE_RELEASE_STRIP=none` and report stripped sizes + separately. +- Following the tooling questline, the work lives in a `sh/` script behind a + `just` recipe, and `nightly.yml` calls the recipe. + +Considered and declined (do not re-ask): + +- Shrinking npm install size by dropping sourcemaps or `inlineSources`. Maps + are 60-77% of the unpacked packages, but they never reach a browser and + they give consumers real stack traces. +- Splitting the lite and IETF implementations out of `@moq/net`: version + negotiation needs both at connect time. +- Feature gates in moq-tokio for reqwest, tracing-subscriber, usage-rs/toml, + and tokio "full" in the bindings. +- Replacing the AV1 codec-string regex in hang. tracing-subscriber's env + filter keeps regex linked anyway. + +## Related + +- [Release profile](/quest/m1/release-profile.md) - the profile this report measures +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - the same nightly-trend shape for Criterion diff --git a/quest/m1/slow-group-log.md b/quest/m1/slow-group-log.md new file mode 100644 index 0000000000..91aa5d576f --- /dev/null +++ b/quest/m1/slow-group-log.md @@ -0,0 +1,24 @@ +# [XS] A starved viewer reports skipped groups once + +## Goal + +When `@moq/watch` falls behind and skips groups past the max age, it logs one +summary per catch-up instead of one warning per skipped group, so a starved +page does not spend its remaining CPU on console traffic. + +## Plan + +`#checkMaxAge` in `js/hang/src/container/consumer.ts` calls `console.warn` +for every group it drops. A 2.5 ms Opus track is one group per frame, so a +page behind on it warns hundreds of times a second. In an interop `go -> js` +cell at load average 182, the subscriber logged 7151 `skipping slow group: +track=tone` lines in 149 s, and Playwright could not resolve the Pause button +for 30 s. Starvation was the cause; the warnings, each forwarded over CDP, +made it deeper. + +Report the skipped range and count once per `#checkMaxAge` call, the way +`watch/src/sync.ts` summarizes late frames. + +## Related + +- [More tests under load](/quest/m1/test-flakes-2.md) - other load-only failures, fixed at the cause diff --git a/quest/m1/splice-edges.md b/quest/m1/splice-edges.md new file mode 100644 index 0000000000..aa0d904500 --- /dev/null +++ b/quest/m1/splice-edges.md @@ -0,0 +1,37 @@ +# [S] Splice edge cases + +## Goal + +Three spliced-track cases in `rs/moq-net` stop losing or mis-judging groups, +each with a regression test that fails before its fix: + +- A group whose successor segment's first servable group is still unstamped + keeps an unbounded reach, instead of borrowing a later segment's start and + being skipped or ended with `Error::Old`. +- A reader still draining a pruned segment has its boundary group judged + against the later segments, so a stale boundary group is skipped like any + other. +- A reader draining a warm head keeps it when the upstream fails over again + before the track parks. + +## Plan + +Codex raised all three on https://github.com/moq-dev/moq/pull/4103 and +https://github.com/moq-dev/moq/pull/4104 after the fixes there landed; none +was answered. + +- `served_start` in `rs/moq-net/src/model/resume.rs` says an unstamped group + stops the search, but `find_map` reads a segment's `None` (no group, or its + first group has no frame yet) as a miss and moves on. The two cases need + to be distinguishable, as the per-track `served_start` in + `rs/moq-net/src/model/track.rs` already treats them. +- `ResumeState::successor` finds the cursor's segment by id, so a segment + `prune` removed while a reader still drains it yields no successor at all. + Resolve it from the boundary and the remaining later segments instead. +- An ordinary takeover in `rs/moq-net/src/model/origin.rs` (`Action::Splice` + with no warm copy) overwrites `io.head` with `None`. Dropping the + `WarmGroup` aborts the cached head that a reader resumed from an earlier + park may still be reading, and the next park cannot rebuild the full group. + Keep the existing head when the splice has no new one to take. + +These are independent; one PR is fine since they share the splice tests. diff --git a/quest/m1/stats-producer-bench.md b/quest/m1/stats-producer-bench.md new file mode 100644 index 0000000000..869404f06b --- /dev/null +++ b/quest/m1/stats-producer-bench.md @@ -0,0 +1,33 @@ +# [S] Stats producer fan-out benchmark + +## Goal + +A `moq-stats` benchmark measures what one relay pays per stats tick to drain +its registry and encode the traffic tracks, swept over held paths and tiers, +so a cost that grows with the whole table instead of the paths that changed +shows up as a slope. It runs at least nightly. + +## Plan + +- [#4299](https://github.com/moq-dev/moq/pull/4299) keeps an idle path in + every frame while the registry holds its counters, adding about 430 B per + idle path to each plain frame (maintainer's note on the PR). Nothing + measures what that, or the per-tick drain, costs as held paths grow. + `rs/moq-stats/benches/decode.rs` covers only the reader. +- Sweep held paths (for example 100 to 50k) against tiers, and the share of + paths that are idle versus changed this tick. Report time and allocations + per tick, and plain and compressed bytes per frame, the way `decode.rs` + reports its table. Include the point where a plain frame nears its size + cap, since a frame that is too large leaves stale counters behind. +- Drive the real producer path (`process_slot`, the snapshot encoders) over + a synthetic `Registry`, not a re-implementation of it. Exposing a bench + hook is fine if it stays out of the public API. +- `decode.rs` is not in CI either. Run both targets once in the nightly + benchmark smoke, as `moq-net`'s are. + +Public API: none. Wire: none. + +## Related + +- [Binary delta stats flavor](/quest/m2/stats-delta.md) - its gate needs the encode baseline this measures +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - compares these targets across PRs once it lands diff --git a/quest/m1/subscribe-drop.md b/quest/m1/subscribe-drop.md new file mode 100644 index 0000000000..3f93210d63 --- /dev/null +++ b/quest/m1/subscribe-drop.md @@ -0,0 +1,50 @@ +# [L] SUBSCRIBE_DROP accounts for every group + +## Goal + +A lite subscriber can tell "not yet" from "never" for every stream group in +its subscription. Each sequence in range either arrives on a Group Stream, is +sent as a datagram, or is named by a SUBSCRIBE_DROP, including sequences the +publisher skipped. Only a lost datagram stays unaccounted. Rust and +`@moq/net` publishers send it on every lite version that has it, and moq-lite-07 +brings it back in place of `Stream Count`. + +## Plan + +Today SUBSCRIBE_DROP is on the wire for lite-03 through lite-06 and the Rust +subscriber accounts for it, but no publisher sends it. lite-07 (still +`moq-lite-07-wip`, unpublished) removed it for a `Stream Count` on +SUBSCRIBE_END (#4224). + +Decided: + +- lite-07 restores SUBSCRIBE_DROP and removes `Stream Count`. With every + sequence accounted for, the count is redundant; one mechanism instead of two. + A reset whose header may be lost is covered by a DROP. +- A reliable reset ([Reliable stream reset](/quest/m1/quic/reliable-reset.md)) + that keeps the stream header acts as a one-group drop, an optimization over + sending the DROP. +- Publishers send SUBSCRIBE_DROP on lite-03 through lite-06 too: for every group + in range they won't deliver (expired, deprioritized, or reset without its + header delivered) and for every explicit gap. Publishers that skip sequences + (`cut` and group discontinuities in the media layers) must mark the gap so + the net layer can drop it. +- Datagram groups stay best effort. A publisher counts a datagram as + delivered, so a lost one leaves an uncovered hole that waits out the tail + grace, as today. +- A resumed group ([Resumed groups](/quest/m1/resume-latest.md)) that is the + new copy's latest ends with the DROP's error when the copy drops it. + +Update `drafts/draft-lcurley-moq-lite.md` (SUBSCRIBE_DROP, SUBSCRIBE_END, the +lite-07 changelog), `doc/concept/moq-lite.md`, and the Rust and JS lite +publishers, subscribers, and tail accounting. Run `just drafts check` and +`just test interop --all`. + +Regression tests: a publisher that expires a group, skips a sequence, and +resets a stream before its header; on each version the subscriber settles +without waiting out the grace. + +## Related + +- [Track tail hardening](/quest/m1/track-tail-hardening.md) - the same tail accounting, in both languages +- [Track tail interop](/quest/m1/track-tail-interop.md) - the Rust-JS proof of the lite-07 drop case diff --git a/quest/m1/test-flakes-2.md b/quest/m1/test-flakes-2.md new file mode 100644 index 0000000000..c1742598ca --- /dev/null +++ b/quest/m1/test-flakes-2.md @@ -0,0 +1,56 @@ +# [S] More tests hold up under load + +## Goal + +A second round after [#4286](https://github.com/moq-dev/moq/pull/4286): +tests that pass alone but have failed under a loaded `just check` pass +reliably, each fixed at its cause, never by raising a timeout or adding a +retry. + +- moq-cli `fetch::tests::a_frame_read_times_out` asserts on a 500 ms wall + deadline ([#4084](https://github.com/moq-dev/moq/pull/4084)). +- moq-cli `complete::tests::a_stage_broadcast_picks_the_catalog_to_read` + ([#4084](https://github.com/moq-dev/moq/pull/4084)) and + `the_catalog_format_on_the_line_is_honored` + ([#4089](https://github.com/moq-dev/moq/pull/4089)). +- moq-net `model::group::test::drop_unfinished_warns` counts WARNs through a + global tracing capture, so another test's WARN, or a missed one, changes + the count ([#4104](https://github.com/moq-dev/moq/pull/4104)). The + `model::track` test of the same name uses the same helper. +- moq-tokio `broadcast_race_quic_wins` binds TCP `:0` and then UDP on the + same number, which nothing reserves: the collision + [#4084](https://github.com/moq-dev/moq/pull/4084) removed from its sibling + after [#4055](https://github.com/moq-dev/moq/pull/4055) papered over it with + a retry. +- `just test media` late join failed once after + [#4181](https://github.com/moq-dev/moq/pull/4181): "joined at frame 111, 16 + frames behind 127", against a budget of one GOP (15). + +## Plan + +- Timing tests: prefer a paused clock over wall time (`moq-cli`'s + subscribe tests already use `#[tokio::test(start_paused = true)]`), or + assert on an event instead of a deadline. If a test is slow under load + because the code under test is slow, fix that. +- WARN counting: capture per test (a scoped subscriber or a filter on the + test's own span) instead of a process-global count. +- The race test shares one port only so both transports sit behind one URL. + Separate `:0` ports fix the bind collision but not the race itself: with + `websocket.delay = 0` either arm can legitimately win under load. Make the + order deterministic the way #4084 did, holding the WebSocket arm until QUIC + has connected, rather than asserting on a real race; no retry. #4084's follow-ups + (`tests/reconnect.rs` `spawn_server`, `tests/worker.rs` `free_udp_port`) + are the same probe-and-rebind pattern; fix them here if cheap. +- Media late join: first decide whether 16 frames is a real regression (the + player joining at the previous GOP's keyframe) or an off-by-one in how the + fixture samples the live edge. Fix whichever it is; don't widen the + budget without a reason. +- Prove it by running `just check --all` several times on a loaded machine, + as the first round did. + +Public API: none. Wire: none. + +## Related + +- [Archive enrollment](/quest/m1/archive/enrollment-flake.md) - the same + kind of flake on the archive line, where its test lives diff --git a/quest/m1/test-flakes.md b/quest/m1/test-flakes.md deleted file mode 100644 index 909d6cbc28..0000000000 --- a/quest/m1/test-flakes.md +++ /dev/null @@ -1,22 +0,0 @@ -# [M] Tests hold up under load - -## Goal - -Three tests that pass alone but fail under a full `just check` pass reliably, -fixed at the cause rather than by raising a timeout or adding a retry: - -- `js/json/src/snapshot/snapshot.test.ts:359`, "a compressed delta is gated - on its encoded size", which takes about 4.3 s against a 5 s limit. -- The `js/net/src/declarations.test.ts` test that times out at 5 s. -- `rs/moq-tokio/tests/backend.rs:739` `noq_cert_reload`, which fails with - "Too many open files". - -## Plan - -- Find why each JS test is slow. It should shrink its input or reveal a real - slowdown in the code under test; fix whichever it is. -- For `noq_cert_reload`, find what holds the descriptors: a leak in the test - or code under test, or nextest parallelism against the file limit. Fix a leak - at its source, and otherwise cap the test's concurrency in - `.config/nextest.toml`. -- Prove it by running `just check --all` several times on a loaded machine. diff --git a/quest/m1/tooling/README.md b/quest/m1/tooling/README.md index 073918b847..5f3055bfc0 100644 --- a/quest/m1/tooling/README.md +++ b/quest/m1/tooling/README.md @@ -17,7 +17,7 @@ fix`) is in every doc, skill, and workflow. Its cost was self-inflicted: logic inside recipes. The quests below are ordered and each requires the one before it, so they land as one line of pull requests. -## Quests +## Required - [Thin justfiles](/quest/m1/tooling/justfiles.md) - recipe bodies move to `sh/`, one impact map scopes check/fix/test, self-tests and guards are deleted - [Workflows call just](/quest/m1/tooling/workflows-call-just.md) - no workflow `run:` step names a `.sh`; every script a workflow needs has a recipe diff --git a/quest/m1/track-demand.md b/quest/m1/track-demand.md index de9fc5ea86..82fdcafe4c 100644 --- a/quest/m1/track-demand.md +++ b/quest/m1/track-demand.md @@ -15,12 +15,8 @@ moq-binary, moq-relay, moq-transcode, moq-stats, and moq-c. Both waits already surface the track's abort reason, so callers keep their errors. Keep `abort_unused` if its race still needs an owner. JS mirrors the Rust shape in `js/net`. -Lands after the release, alongside the [FFI shape](/quest/m1/ffi-shape/README.md) -line, so moq-net breaks once. Its PR retargets to `dev`. +Lands on `dev` alongside the [FFI shape](/quest/m1/ffi-shape/README.md) +line, so moq-net breaks once. Public API: breaking in moq-net and the layer crates, and in `@moq/net`. Wire: none. - -## Required - -- [Release](/quest/m0/release.md) - the break follows the release diff --git a/quest/m1/track-tail-hardening.md b/quest/m1/track-tail-hardening.md new file mode 100644 index 0000000000..74d412bd04 --- /dev/null +++ b/quest/m1/track-tail-hardening.md @@ -0,0 +1,79 @@ +# [M] Track tail hardening + +## Goal + +moq-net and `@moq/net` wait out a subscription's tail by the same rules, and +the known holes in those rules are closed. A finished IETF subscription never +hangs its request task or under-reports its stream count, a group whose +header arrived is never silently dropped from a clean end, the grace never +cuts off a stream still being read, and tail bookkeeping stays bounded on a +lossy track. + +## Plan + +Track tail landed in https://github.com/moq-dev/moq/pull/4086 (JS) and +https://github.com/moq-dev/moq/pull/4116 (Rust), and Codex's findings on both +were left for this pass. Fix each in the language named and check whether +the other has the same hole. + +Bugs: + +- **Rust IETF publisher, END_OF_TRACK not cancellable.** After the group + streams drain, `write_end_of_track` in `rs/moq-net/src/ietf/publisher.rs` + runs outside the race against `stream.reader.poll_closed` and the session, + so with uni-stream credit exhausted an unsubscribe leaves the request task + parked forever, never sending PUBLISH_DONE. +- **Rust IETF publisher, stream count.** END_OF_TRACK is counted only if the + whole write succeeds, while every other stream counts once opened. A reset + END_OF_TRACK whose header still arrives can then land after the subscriber + met the count and retired the alias. +- **Rust IETF subscriber, truncated first object.** `open_group` peeks the + first object with `?` before creating the group, so a stream reset or + truncated after its header drops that group, and since the stream still + counts, the track can end clean without it. Keep the END_OF_TRACK peek, but + create and abort the named group on any other peek failure. +- **Rust grace cuts off an arrived stream.** `Settle::poll` in + `rs/moq-net/src/tail.rs` returns when the grace fires even if an + END_OF_TRACK stream is mid-read, so the track finishes at the live edge and + the later marker is ignored. JS's `Tail.settle` already waits for active + streams; do the same. +- **JS lite floor.** `Math.max(entry.start, bounds.start)` in + `js/net/src/lite/subscriber.ts` ignores a SUBSCRIBE_UPDATE that lowered the + floor after SUBSCRIBE_START, so a newly requested lower group reordered + behind the FIN is dropped. `bounds.start` alone is wrong too: a subscriber + that asked below the announced start would wait for groups never promised. + The owed start has to remember the floor each request was answered at. + Check whether Rust's `SubStream::owed` has the same shape. + +Open questions. The maintainer chose to plan these without settling them; +decide in the PR, applying the choice to both languages: + +- **Are lost datagrams owed before a track ends?** Both `Tail`s account a + datagram only when it arrives, so a lost one leaves a hole in the owed span + and the end waits out the whole grace (JS was reported to wait and Rust + not, but Rust's `covers(owed)` reads the same way despite the comment in + `route_datagram`; confirm with a test first). Recommendation: no, datagrams + are best effort. [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) decided + the same: datagram groups are not owed, so an uncovered hole waits out the + grace on every version. +- **Does a group at or past the declared end abort the track, or only that + group?** Rust aborts the track with `ProtocolViolation`; JS aborts the group + and ends clean. Recommendation: abort the track in both. The peer + contradicted its own end, and the repo fails loud on malformed input. +- **How is tail memory bounded?** Rust `Tail.accounted` gains a range per + permanent gap for the life of the subscription, and so does JS's. A gap + older than the grace can no longer be waited for, so folding it in as + accounted loses nothing. Recommendation: that, which bounds the ranges by + the gaps inside the grace window. Once publishers send SUBSCRIBE_DROP for + every gap, only datagram holes remain. + +Regression tests go in `rs/moq-net/tests/track_tail.rs` (its `hold_unis` +mock makes the reorder deterministic) and the JS tail tests. Wire output only +changes if the stream-count fix does, and that is a correction to what the +drafts already require. + +## Related + +- [Track tail interop](/quest/m1/track-tail-interop.md) - the Rust-JS check that both sides now agree +- [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - every group is a stream or a drop, replacing lite-07's stream count +- [Session death](/quest/m1/session-death.md) - how a tail ends when the session dies under it diff --git a/quest/m1/track-tail-interop.md b/quest/m1/track-tail-interop.md index 7489cbdfb3..01b0a68338 100644 --- a/quest/m1/track-tail-interop.md +++ b/quest/m1/track-tail-interop.md @@ -25,5 +25,24 @@ What stood in the way when the Rust half landed: first frame. It needs a mode that reads a track to its end and reports how it ended and which groups it saw. +Also cover the drop case: + +- On moq-lite-07 add a drop case: both subscribers settle on SUBSCRIBE_DROP, + so a group the publisher skipped or never opened ends the track without + waiting out the grace. lite-07 replaces the SUBSCRIBE_END stream count + (#4224) with it. +- The harness exposed a relay start-floor defect: when a newer group arrives + first, earlier in-flight groups can be lost. #4387 fixes it, so the drop + proof waits on it. + QUIC on localhost rarely reorders, so this is a smoke check that the end is delivered and clean. The ordering race itself stays in the unit tests. + +## Required + +- [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - publishers name every group they won't deliver, which the lite-07 case checks +- #4387 merges: a relayed subscription resolves its start from its source, so earlier in-flight groups are not lost (it adds quest/m1/relay-late-joiner-history.md) + +## Related + +- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - keeps a reset stream's header, so the reset acts as a one-group drop diff --git a/quest/m1/transport-upgrade/README.md b/quest/m1/transport-upgrade/README.md index 7e88b12e6f..094817c6e0 100644 --- a/quest/m1/transport-upgrade/README.md +++ b/quest/m1/transport-upgrade/README.md @@ -34,8 +34,8 @@ forbidden to a moq-transport client). The origin's multi-route front prefers the newest of two equal routes and `resume` splices each track at a group boundary, capping the old segment so the old session's subscription ends at the boundary on its own. The JavaScript handover is the -[client goaway](/quest/m1/drain/client-goaway.md) quest's, so the JS half -requires it. +[drain](/quest/m1/drain/README.md) line's (client goaway, done there), so the +JS half requires it. Shared decisions: @@ -55,7 +55,7 @@ Shared decisions: per-session origin makes that a replacement rather than a join, which is immediate either way. -## Quests +## Required - [Rust](/quest/m1/transport-upgrade/rust.md) - moq-tokio keeps the QUIC dial after WebSocket wins and migrates through the existing Draining path - [JavaScript](/quest/m1/transport-upgrade/js.md) - js/net keeps the WebTransport dial after WebSocket wins and migrates through the client-goaway handover diff --git a/quest/m1/transport-upgrade/js.md b/quest/m1/transport-upgrade/js.md index 4cb58a3c59..f5c8562035 100644 --- a/quest/m1/transport-upgrade/js.md +++ b/quest/m1/transport-upgrade/js.md @@ -11,8 +11,9 @@ nothing changes. ## Plan -Lands in `js/net`, after the [client goaway](/quest/m1/drain/client-goaway.md) -quest ships the handover it reuses: dial the replacement while the old session +Lands in `js/net`, after the [drain](/quest/m1/drain/README.md) line ships +the GOAWAY handover it reuses (client goaway is done on the line, JS +group-boundary handover is its remaining child): dial the replacement while the old session keeps serving, swap the origin wiring once it is established, leave the old session to close on its own or at the handover cap. See the [questline](/quest/m1/transport-upgrade/README.md) for the shared decisions. @@ -31,6 +32,9 @@ session to close on its own or at the handover cap. See the may not name a redirect URI, and an empty one is legal) and closes at the configured cap. A WebTransport attempt that fails after WebSocket won is logged at debug and the session stays on WebSocket. +- A self-sent GOAWAY must gate new requests on the old session too, not only + a received one: [JS GOAWAY requests](/quest/m1/drain/js-goaway-requests.md) + covers the received case, so check it also covers this path. - On a successful upgrade delete the URL from `websocketWon`. - Tests in the browser harness against the in-tree relay: with the WebTransport dial delayed past the head start, a watched track keeps every @@ -43,4 +47,5 @@ session to close on its own or at the handover cap. See the ## Required -- [Client goaway](/quest/m1/drain/client-goaway.md) - the handover this upgrade reuses +- [Drain](/quest/m1/drain/README.md) - the GOAWAY handover this upgrade reuses +- [JS GOAWAY requests](/quest/m1/drain/js-goaway-requests.md) - no new request opens on a session that is going away diff --git a/quest/m1/ts-export-byte-schedule.md b/quest/m1/ts-export-byte-schedule.md index 4eb0f15454..8427325a68 100644 --- a/quest/m1/ts-export-byte-schedule.md +++ b/quest/m1/ts-export-byte-schedule.md @@ -27,7 +27,3 @@ UDP sink is out of scope; delivery stays with an external tool. it in the existing TS test recipe against a CBR fixture. - `doc/bin/cli.md`: say that export pads to `mpegts.muxRate` on a constant-rate schedule and what latency that adds. - -## Closes - -- [#3925](https://github.com/moq-dev/moq/issues/3925) - close this issue when the quest finishes diff --git a/quest/m1/ts-export-jitter.md b/quest/m1/ts-export-jitter.md new file mode 100644 index 0000000000..5e0b138748 --- /dev/null +++ b/quest/m1/ts-export-jitter.md @@ -0,0 +1,57 @@ +# [S] moq export ts: video reorder bound follows the stream + +## Goal + +Two `moq export ts` legs of one broadcast interleave video and audio +identically even when a B-frame arrives late. Today the media-time interleave +from [#4001](https://github.com/moq-dev/moq/pull/4001) treats a video track as +advanced past a candidate once its high-water mark, less its DTS reserve, +passes it. Without catalog `jitter` that reserve is the 16-tick +`DEFAULT_DTS_RESERVE`, so a B-frame presenting below the mark can still land +after audio it should precede, and the order depends on arrival again. A +`jitter` that shows up in a later catalog is ignored too: `update_catalog` +returns early once the PAT/PMT is built, before the per-track refresh. + +## Plan + +Decided: + +- Keep refreshing a video track's reserve from catalog `jitter` after the PMT + is emitted. The early return exists to lock the track layout, not the + per-track timing, and the importer often fills `jitter` only once it has + seen reordering. +- When `jitter` is absent, derive the reserve from the stream rather than + refuse it. The bitstream's reorder depth (H.264 VUI + `max_num_reorder_frames`, HEVC `sps_max_num_reorder_pics`) counts + pictures, not time, so it becomes a deterministic 90 kHz bound only with + fixed timing: the VUI `fixed_frame_rate_flag` with its tick, or the + catalog framerate. With both, the reserve is depth times frame duration, + known from the first keyframe before any frame is emitted. AV1 and VP9 + present in decode order and need no reserve. Otherwise (variable frame + rate, or nothing declared), grow the reserve from the reordering actually + observed (how far a frame's PTS falls below the track's high-water mark); + it only grows, so a stream that never reorders keeps today's tiny reserve. +- Refusing to export video without `jitter` was rejected as too extreme. +- Treating unknown jitter as unbounded was tried in #4001 and rejected: the + stall never clears when video leads, breaking + `quiet_track_is_emitted_around_then_rejoins`. + +Guidance: + +- The same reserve feeds `author_dts`, so growing it mid-stream shifts DTS + further behind PTS. The existing monotonic clamp keeps DTS from stepping + back; check the PCR, which backs off by the largest reserve, stays ahead of + every DTS written. +- Only the observed fallback is nondeterministic: a B-frame deeper than any + seen before can reorder output once per new maximum (Codex on #4307). + Refusing such streams was ruled out by the maintainer. Say + so in a comment and log each growth, so an undeclared stream is visible + rather than silently misordered. +- Tests in `export_test.rs`: two exporters with a late B-frame, no `jitter`, + and a declared reorder depth at a fixed frame rate produce byte-identical + output from the first frame; an undeclared stream converges after its deepest reorder; and a + `jitter` arriving in a catalog after the PMT raises the reserve. + +## Related + +- [TS export byte schedule](/quest/m1/ts-export-byte-schedule.md) - also reshapes PCR placement in `export.rs` diff --git a/quest/m1/ts-import-shared-shift.md b/quest/m1/ts-import-shared-shift.md new file mode 100644 index 0000000000..e5c9190bd3 --- /dev/null +++ b/quest/m1/ts-import-shared-shift.md @@ -0,0 +1,51 @@ +# [S] moq import ts: one re-anchor shift per program + +## Goal + +An unflagged loop wrap in `moq import ts` moves audio and video forward by the +same amount, so A/V sync holds across any number of wraps. Today each +elementary stream owns a `Reanchor` and grows its shift to reach its own live +edge ([#3997](https://github.com/moq-dev/moq/pull/3997)). Audio and video +edges sit on their own last frame starts, so each wrap drifts A/V by the +difference in frame durations: about 12 ms at 30 fps with 48 kHz AAC, or +1.7 s a day on a 10-minute loop. + +## Plan + +Decided: a program-level shared shift in `rs/moq-mux/src/container/ts/import.rs`. +Every stream of a program applies the same shift, grown once per wrap by the +largest amount any stream needs to clear its edge, which preserves the source's +inter-stream offsets. A timebase break (PCR discontinuity) clears it for the +whole program, as it already does per stream. + +Guidance: + +- The shift must be known before any stream emits a frame of the new + generation (Codex on #4307): a first stream that grows only enough for + itself leaves a later-arriving stream below its edge, and growing again + then drifts the two. Decided: hold each stream's post-wrap frames until + every live stream of the program has shown its new PTS, then take the + maximum growth once. +- The hold needs its own bound: #3489 adds per-PID counters, not a timeout, + and the catalog `stalled` bit (`Stream::tick`, #3630) covers video only and + runs on the catalog's timer. Decided: bound the hold on the program clock + (PCR advance since the first stream's new generation, not wall time), and + commit the shift over the streams seen so far when it expires. A stream + that returns later applies the committed shift, clamped to its edge as + below. The `Anchor`/`Lane` split behind `live()` in `moq_mux::clock` solves + a similar problem for restarts, but the remove-live quest deletes it, so + copy what helps rather than depending on it. +- A stream whose own edge is still above the shifted timestamp after the + shared growth (its tail ran longer) is the case that forces growing by the + maximum. Landing on its edge is accepted today; keep that trade-off. +- Sections already take the video shift, so cues keep following pictures. + MPEG-2 video (`Stream::Clock`) has no track and so no edge, but it should + read the shared shift too. +- Tests beside the existing loop-wrap tests: a muxed H.264 + AAC loop whose + period is not a multiple of either frame duration keeps the first audio and + video timestamps of each pass at the source offset across three wraps. + +## Related + +- [#3489](/quest/m1/3489-ts-import-stream-liveness.md) - per-PID liveness in the same importer; touches `Stream` but not the shift +- [Remove live()](/quest/m1/remove-live.md) - deletes the restart anchor; a wrap shift stays input-derived diff --git a/quest/m1/unknown-session-logs.md b/quest/m1/unknown-session-logs.md new file mode 100644 index 0000000000..4b62e1730c --- /dev/null +++ b/quest/m1/unknown-session-logs.md @@ -0,0 +1,43 @@ +# [S] UnknownSession log flood + +## Goal + +moq.pro relays stop logging +`web_transport_moq::session: failed to decode unidirectional stream err=WebTransportError(UnknownSession)` +for streams that were simply reset or cut off before their WebTransport +header arrived. A stream that really names another session is still reported +as `UnknownSession`, and the error a caller sees says what happened. + +## Plan + +Seen in production at a rate like the old-group warnings +https://github.com/moq-dev/moq/pull/4208 fixed, on the same hosts. + +Likely root cause, to confirm with a test first: `decode_uni` (and +`decode_bi`) in `web-transport-moq`'s `session.rs` (repository +`moq-dev/noq`) map every failure to read the stream type or session ID to +`UnknownSession`. A read fails whenever the peer resets the stream before +those bytes arrive, which moq does routinely: a publisher resets a group's +stream when the group is superseded or expires, and without reliable reset +the header can be discarded with it. `poll_accept_uni` then logs it at WARN, +though its own comment says the stream "was probably reset early". + +- Reproduce in `web-transport-moq`'s tests: open a WebTransport uni stream, + reset it before the header is delivered, and assert the accept loop + reports a reset rather than `UnknownSession`. +- Fix the mapping at the source: carry the read's real cause (reset, closed, + truncated), keep `UnknownSession` for a session-ID mismatch, and log the + expected reset at debug. Not a log filter on the moq side. +- `web-transport-quinn` in `moq-dev/web-transport` carries the same code; + fix it too if it is still published. +- Release and bump the pin here. Confirm on a moq.pro relay that the rate + drops, and that any remaining `UnknownSession` lines are real mismatches. + +If the reproduction shows something other than early resets (for example a +peer really using another session ID), stop and report before changing the +log level. + +## Related + +- #4262 - the close-code fix in the same crate, on `dev` +- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - keeps a reset stream's header, which removes most of these diff --git a/quest/m1/uring-tcp/README.md b/quest/m1/uring-tcp/README.md index bfcd62906c..f18fc50537 100644 --- a/quest/m1/uring-tcp/README.md +++ b/quest/m1/uring-tcp/README.md @@ -33,7 +33,7 @@ Measure before porting, the same way the echo bench (rs/moq-uring/benches/session_lite.rs) gated the UDP path. The ablation's number is what justifies the rest of the line. -## Quests +## Required - [Ablation](/quest/m1/uring-tcp/ablation.md) - measure ring TCP against tokio TCP under the qmux workload before committing to the port diff --git a/quest/m1/video-surface.md b/quest/m1/video-surface.md new file mode 100644 index 0000000000..d5e7cdc9f5 --- /dev/null +++ b/quest/m1/video-surface.md @@ -0,0 +1,36 @@ +# [XS] FFI decoded frames expose a surface, only where one exists + +## Goal + +moq-ffi names the decoder's retained picture the way moq-video does, and a +caller cannot opt into it on a platform that has none. On `dev` +([#4094](https://github.com/moq-dev/moq/pull/4094)) the frame's view is +`MoqVideoNative` from `native()`, enabled by `MoqVideoDecoderOutput.native`. +Only macOS has a variant (`PixelBuffer`). Elsewhere the opt-in still decodes +to native surfaces, `native()` always returns `None`, and a tiled VAAPI +DMA-BUF also fails `pixels()`, so the caller gets frames it cannot read. + +## Plan + +Decided: + +- Rename to surface naming, mirroring `moq_video::Surface`: + `MoqVideoSurface`, `surface()`, and the matching `MoqVideoDecoderOutput` + flag. "Native" named today's implementation, not the role. +- Refuse the opt-in at subscribe time on platforms with no surface variant + (Windows and Linux today) with a clear unsupported error. Each platform + lifts the refusal when its variant lands with hardware proof, in + [decode-windows](/quest/m1/obs-moq-video/decode-windows.md) and + [decode-linux](/quest/m1/obs-moq-video/decode-linux.md). + +Guidance: + +- Whether `moq_video::Output::Native` should follow the rename is a + separate call; ask before touching it. +- Update the wrappers and docs per the root `AGENTS.md` sync table: the Go, + Python, Swift, and Kotlin option docs mention the native surface flag, and + `quest/m1/obs-moq-video/source.md` and the release plan on `dev` name + `native`. +- A `dev` break on top of #4094, so it rides the same release. +- Test: the opt-in is refused where there is no variant, and on macOS + `surface()` returns the pixel buffer. diff --git a/quest/m1/watch-audio-time-stretch.md b/quest/m1/watch-audio-time-stretch.md index 15e13d7cff..0ceb0850b6 100644 --- a/quest/m1/watch-audio-time-stretch.md +++ b/quest/m1/watch-audio-time-stretch.md @@ -9,8 +9,9 @@ audio or rendering silence. Convergence is inaudible at ordinary drift and burst sizes. Boundaries: no packet loss concealment; an underrun still renders a ramped -gap. The target estimator and the ring's slack and re-stall are #3517 on -dev; the clock the stretch converges toward is +gap. The target estimator and the ring's slack and re-stall are the +[audio jitter target](/quest/m0/audio-jitter-target/README.md) line (#3517 +was closed in favor of it); the clock the stretch converges toward is [Plan: A/V clock](/quest/m0/plan-av-clock.md). ## Plan diff --git a/quest/m1/watch-refusal.md b/quest/m1/watch-refusal.md new file mode 100644 index 0000000000..4412cf6113 --- /dev/null +++ b/quest/m1/watch-refusal.md @@ -0,0 +1,38 @@ +# [S] Watch shows a refusal + +## Goal + +When the origin refuses the broadcast `` asks for, the player shows +that refusal as an error instead of sitting offline as if nothing was +published yet. Refusal stays terminal, as +[#4230](https://github.com/moq-dev/moq/pull/4230) made it in `@moq/net` to +match Rust moq-net: the player never re-asks a handler that already said no. + +## Plan + +- The gap is Codex's P1 on #4230 + ([r4109909244](https://github.com/moq-dev/moq/pull/4230#discussion_r4109909244)): + `js/watch/src/broadcast.ts` only watches `request.active`, so after a + `dynamic()` handler refuses, the request closes with an error that nobody + reads and `active` stays `undefined` forever. +- Observe `Requesting.closed` and carry the error into the broadcast's + state. Whether that is a new `"error"` status, a separate error signal, or + both is open; mirror how the element already surfaces other terminal + states, such as the unsupported indicator. Keep the error's message so the + UI can say why. `unroutable` is also true for a path nothing serves yet, so + it cannot tell a refusal from offline. +- What clears the error is part of the design: a fresh request (a new + `name` or origin, or re-enabling) should be the only way back. No retry + loop. +- Cover both the announced and unannounced paths in `#runBroadcast`. +- Show it in the UI, and update `demo/web` if it consumes the status. Add a + test in `js/watch` where a `dynamic()` handler refuses and the broadcast + reports the error. +- Update `doc/` wherever the watch status values are documented. + +Public API: likely additive (a new status value or error signal on the watch +broadcast and element). Wire: none. + +## Related + +- [#4230](https://github.com/moq-dev/moq/pull/4230) - made JS refusals terminal diff --git a/quest/m1/wildcard/demand.md b/quest/m1/wildcard/demand.md deleted file mode 100644 index 9cd10f88fd..0000000000 --- a/quest/m1/wildcard/demand.md +++ /dev/null @@ -1,49 +0,0 @@ -# [S] Demand - -## Goal - -The browser player subscribes to a catalog-referenced broadcast a wildcard -covers, instead of hiding renditions that are not announced. Without this, a -lazily-produced rendition is a deadlock: the encoder starts on demand, and the -player never demands what it hides. - -## Plan - -The gate is JS-only, and it is already prefix-aware. -[moq#3225](https://github.com/moq-dev/moq/pull/3225) made `#isPathAnnounced` -(`js/watch/src/broadcast.ts:216`) hold the set of announced prefixes and accept -any that covers the path, so a route at `room/` already makes -`room/alice/cam.hang` selectable without naming it. What it cannot do is match -a pattern, since it tests with `Path.hasPrefix` (`:223`). - -So the remaining work is narrow: teach the JS client the wildcard -advertisement (`js/net/src/announced.ts` and `js/net/src/lite/announce.ts`, -mirroring the moq-net wildcard advertisement) -and make the covering test use `Path.Pattern` (`js/net/src/path.ts:526`) -rather than prefix containment. -Withdrawal of the last covering wildcard hides the rendition again, the same -reactive shape announcements have today. - -Do not simply delete the gate. It exists so the player does not subscribe to -absent broadcasts and so renditions appear and disappear reactively with -announcements. The Rust side needs nothing here: `moq-mux::Source` resolves -references through `request_broadcast`, which -[resolve](/quest/m1/wildcard/resolve.md) teaches to consult patterns. - -Two existing soft spots to not reintroduce: the first evaluation runs before -the announcement stream has populated, briefly hiding cross-broadcast -renditions on startup; and a token without announce visibility over the -sibling's path hides it permanently even though a direct subscribe would work. -A covering wildcard fixes the second only if patterns are forwarded under the -subscriber's scope, which advertise's rebasing rule guarantees. - -Tests: a rendition whose broadcast is covered only by a wildcard is listed and -playable, subscribing it is what starts production (the subscribe arrives -before any announcement), the rendition disappears when the last covering -wildcard is withdrawn, and a concrete announcement arriving later changes -nothing visibly. - -## Required - -- [Resolve](/quest/m1/wildcard/resolve.md) - recognizing the wildcard is useless - until the relay routes the resulting subscribe through it diff --git a/quest/m1/wildcard/resolve.md b/quest/m1/wildcard/resolve.md deleted file mode 100644 index c582df54ad..0000000000 --- a/quest/m1/wildcard/resolve.md +++ /dev/null @@ -1,105 +0,0 @@ -# [L] Resolve - -## Goal - -A relay resolves a subscribe or FETCH for an unannounced path against the best -matching wildcard. - -## Plan - -Specificity before cost is an explicit routing policy: a catch-all must not -silently take over a path still claimed by a concrete service, even when that -service refuses the request. Keep the draft and regressions aligned with it. - -[moq#3225](https://github.com/moq-dev/moq/pull/3225) built the table this quest -needs. `Consumer::request_broadcast` resolves a local broadcast first, then -`best_server`, which filters routes to those covering the path, drops any whose -hop chain contains the requester's excluded hop, keeps the longest covering -prefix, and orders the survivors by `route_order` -(`rs/moq-net/src/model/origin.rs:633`). The winning session serves the request -on demand, and `ServeState.served` (`:764`) caches the result per path so -repeat requests share one upstream subscription. A route never passes through -`origin::Dynamic`'s shared FIFO, so requester identity and the hop chain are -both available to selection. - -So this quest extends a working table rather than standing one up: teach the -route entries to hold a pattern instead of only a literal prefix, and teach -selection the tiering and pooling below. Keep the exclusion filter where it is, -applied before selection, so an out-of-band request can never be served back -through the peer that made it. - -Among the survivors, only the tier selected by the matcher's shared structural -specificity is consulted, with equal-specificity patterns forming one pool. A -refusal from that tier never falls through to a less -specific one, so `**/transcode.pro` shadows the archive's `**` for -every transcode path, matched or refused. Selection within the tier is lowest -accumulated cost first, then a hash of the REQUESTED path against each -advertiser's origin id. Keying on the request rather than the pattern is the -whole point: hashing the pattern would hand one advertiser every path matching -it. A concrete announcement is maximally specific and shadows every wildcard -regardless of cost. A terminal refusal from that concrete claim never falls -through to a wildcard; it shadows until the claim is withdrawn. - -Both lookup kinds route through this table: subscribe via `recv_subscribe`'s -existing fallback, and FETCH the same way, since the archive's whole use case -is serving stored groups to FETCH for paths nothing announces. A FETCH selects -the same advertiser through the same hash and completes without installing a -route. - -A served path is not announced downstream, so a wildcard never manufactures -announcements. But a resolved upstream SUBSCRIPTION must be installed as a -ROUTE on the path's origin node, seeded with the wildcard's accumulated cost, -not parked in the request-level `served` cache alone. Preserve the route's -wildcard provenance and specificity: installing an exact-path node must not -promote it into a concrete announcement. A concrete announcement arriving -later lands on the same node and wins by specificity, and the front's -ordinary route change moves consumers at a group boundary. A -cache-only answer would strand every bound consumer on the wildcard -subscription with nothing able to migrate or stop it. When the concrete claim -is a DIFFERENT publisher, its consumers end and resubscribe rather than -splicing, per the first-hop resume rule ([moq#3312](https://github.com/moq-dev/moq/pull/3312)); this quest's -obligation is the route install that makes selection between them possible. Repeat requests for one path -share that one route, keyed to survive the exclusion filter rather than -handing one peer's answer to another. - -An upstream reset is a refusal. A capacity code re-resolves once against the -routing table, with the refusing advertiser excluded from that attempt. The -exclusion is what makes the retry safe: the reset and the advertiser's -retraction travel independently, so the table may not have learned yet, and -re-resolution may equally find no other advertiser and return unroutable. That -is a correct outcome, not a case to handle away. Every other code, and any -unrecognized one, is terminal and propagates. Hold no state either way. - -Tests, at the process level with real sessions rather than an in-process stand-in: - -- One path always selects the same advertiser, and a fixed set of many paths - spreads across advertisers rather than piling onto one. Do not assert that two - particular paths differ: a correct hash may legitimately rank the same - advertiser first for both, so that assertion fails valid implementations. -- A request arriving from a peer that appears in the cheapest wildcard's hop - list selects a clean alternative, or fails unroutable, and never opens a - cyclic subscription. -- Two requesters with different excluded origins do not receive each other's - resolved broadcast. -- A concrete announcement from the SAME publisher takes over from the wildcard - route at a group boundary, without announcement churn; a concrete claim from a - DIFFERENT publisher ends the wildcard-served subscription instead of splicing - into it. -- A high-cost concrete claim shadows a cheaper wildcard, including after - that wildcard has served the path; a terminal concrete refusal does not - fall through while the concrete claim remains advertised. -- A FETCH for an unannounced archived path resolves through the catch-all the - same way a subscribe does. -- A capacity refusal re-resolves onto another advertiser exactly once, and a - second capacity refusal is terminal rather than looping. -- A retraction racing an in-flight request (the request arrives after the - advertiser filled its last slot) ends with the requester served by another - advertiser, or unroutable, but never hung and never looping. -- A permanent refusal does not re-resolve, so a request for a path nobody serves - costs exactly one round trip. -- A path matched by both a suffix pattern and the catch-all resolves against - the suffix pattern's pool only, and a terminal refusal from it never reaches - the catch-all advertiser. -- A refused subscribe resets rather than hanging, and leaves no state behind. -- A wildcard retracted mid-serve does not disturb the subscription already - running. diff --git a/quest/m1/wire-compat.md b/quest/m1/wire-compat.md new file mode 100644 index 0000000000..98cef18c3e --- /dev/null +++ b/quest/m1/wire-compat.md @@ -0,0 +1,61 @@ +# [M] Nightly wire compatibility against the last release + +## Goal + +A nightly run pits this checkout against the last published release and +fails when they stop understanding each other, before the break ships. +It covers, in both directions: + +- **Tokens:** the current `moq-auth` signs and the last published `moq-cli` + and `@moq/auth` verify, and the published side signs while the current + side verifies. +- **Session wire:** the current relay and clients against the last published + `moq-cli`, `moq-relay`, and `@moq/net`, on every lite and IETF version + both sides publish: publish, subscribe, announce, and fetch. +- **Catalog and container:** the last published `hang` and `@moq/hang` read + the current catalog and frames, and the current ones read theirs. + +## Plan + +Motivated by https://github.com/moq-dev/moq/pull/4190, where a token format +change broke every published credential and only moq-dev/smoke#49 noticed. +Neither existing harness asks this question: moq-dev/smoke tests published +against published, and `test/interop` builds every client from the checkout +(see `test/interop/README.md`). + +Decided by the maintainer: + +- **Nightly only**, not per PR. Installing released packages is slow, and + a break only needs catching before the next release. +- **Resolve the last release at run time** (the crates.io and npm registries' + newest non-yanked version), never a hand-bumped pin that goes stale. Log + the resolved versions in the run so a failure names both sides. +- **Not bindings.** Python, Go, Swift, Kotlin, and C stay with smoke and + `test/interop`. They wrap the same Rust, so the Rust lanes see their wire. + +Guidance: + +- Reuse `test/interop`'s relay and client drivers rather than build a second + harness. The new axis is which side comes from the registry, so a + "published" client source next to the checkout one may be enough. +- Derive the version matrix from what both sides accept (for example each + CLI's `--connect-version` choices), not a hand-kept list, so a new draft + joins the matrix and a dropped one leaves it without an edit. A version + only the checkout offers is skipped and logged. A version the last release + supports but the checkout no longer offers fails the run unless it is + acknowledged in the same skip list as planned breaks (decided with the + maintainer; silently dropping a published version is the regression this + exists to catch). +- Prefer released binaries (GitHub release assets) over `cargo install` of + the published crates if the build time threatens the nightly budget. +- Subscribers must decode the catalog and frames, not only see a non-empty + frame, or the container lane proves nothing. +- A deliberate break on `dev` is expected to fail against `main`'s release. + Run against `main` only, and document in the harness how a planned break + is acknowledged (for example a skip list that the next release clears). +- Wire the job into the existing nightly (`interop.yml` already has a + schedule) and document the recipe beside `just test interop`. + +## Related + +- [Track tail interop](/quest/m1/track-tail-interop.md) - another cross-language case in the same harness diff --git a/quest/m1/worker-socket-count.md b/quest/m1/worker-socket-count.md new file mode 100644 index 0000000000..dd6b549c2f --- /dev/null +++ b/quest/m1/worker-socket-count.md @@ -0,0 +1,25 @@ +# [XS] Worker socket count sees only its own listener + +## Goal + +`dropping_a_server_keeps_its_socket` in `rs/moq-tokio/tests/worker.rs` counts +only the sockets its own listener holds, so an unrelated UDP socket elsewhere +on the machine can't fail it. + +## Plan + +`udp_sockets_on(port)` counts every `/proc/net/udp` entry whose local address +ends in the port, so another process's socket on the same port number but a +different address (an ephemeral port on another interface, say) breaks both +the `>= 2` assertion and the final `== 0`. + +- The listener binds `127.0.0.1:0` (`listen_config`), so match the full local + address rather than the port suffix. +- The final `== 0` runs after the port is released, when a parallel test may + bind the same address. If that window matters, count only this process's + sockets by joining the table's inode column with `/proc/self/fd`. Prefer + whichever holds with a stranger on the port at every assertion. +- Reproduce by holding a UDP socket on the same port on another local address + while the test runs. + +Public API: none. Wire: none. diff --git a/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md b/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md index ab9b803e0d..f77cafbf26 100644 --- a/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md +++ b/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md @@ -1,95 +1,44 @@ -# [M] TR 101 290 monitoring: requirements (broadcast/contribution health metrics) +# [S] Plan TR 101 290 monitoring ## Goal -Implement and verify the behavior tracked in [#1838](https://github.com/moq-dev/moq/issues/1838) -within the issue's stated scope and boundaries. +The TR 101 290 stream-health requirements in +[#1838](https://github.com/moq-dev/moq/issues/1838) become scoped +implementation quests with the open questions answered. This quest writes +quests, not code. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. +Run `/quest-plan` against the issue and the current tree. The issue is a +requirements list (ETSI P1/P2/P3 checks, per-check counters, a per-stream +health roll-up) and asks for agreement before any skeleton; its parent +proposal #1799 is closed. -### Issue context +Settled by the issue, carry over: -#### Goal +- TS-level checks run at the TS edges (`moq import ts`, `moq export ts`, + `moq-srt`), never in the media-agnostic relay. +- In today's media-aware lane our muxer regenerates PAT, PMT, PCR, and CC, + so egress checks validate our own output and SI checks are not applicable. + Full P1 to P3 conformance only means something for an opaque whole-mux + lane, which nothing plans yet. +- Results surface through the existing stats plumbing (`moq-stats`), not a new + transport. No remediation (FEC, 2022-7) and no GUI. -For MoQ to replace satellite/contribution links and hand off to IRDs, it needs the -stream-health telemetry operators expect from SRT / Zixi / RIST. ETSI TR 101 290 is the -industry yardstick for MPEG-TS integrity. This issue specifies *what* to monitor and -*where* to surface it, so we can agree scope before building a skeleton. Part of #1799. -No implementation here, requirements only. +Questions the planning session must answer: -#### Where monitoring runs (design question) - -The relay core is media-agnostic by design, so TS-level monitoring belongs at the **TS -edges**, not in the relay: - -- **Ingest** (e.g. `moq-srt`, `moq import ts`): validate the incoming contribution - feed and expose its health. -- **Egress** (e.g. `moq-srt` m=request, `moq export ts`): validate the - TS we hand downstream. - Important nuance from the two-lane model (#1799): -- **Media-aware lane** (today): PAT/PMT/PCR/CC are *regenerated* by our muxer, so at - egress TR 101 290 mostly validates *our own output* (still valuable, catches muxer - regressions like the DTS issue #1836). SI tables beyond PAT/PMT don't exist in this - lane, so those checks are N/A. -- **Opaque whole-mux lane** (future, Option B): the original PCR cadence, CC, and SI - survive, so TR 101 290 validates the *real* contribution feed faithfully. This is where - full P1/P2/P3 conformance is meaningful. - Proposed: implement the checks once over a TS byte/packet stream, run them at both edges, - and surface results through the existing stats plumbing (cf. #1783 connection stats, - \#1671 per-track stats; `moq-net`/`moq-relay` stats modules). - -#### Checks to implement (configurable thresholds; ETSI defaults) - -##### Priority 1 (loss of these = not decodable) - -- TS\_sync\_loss (loss of sync after N consecutive bad sync bytes; default 5) -- Sync\_byte\_error (sync byte != 0x47) -- PAT\_error / PAT\_error\_2 (PAT absent, repetition > 0.5 s, PID 0 wrong table\_id, scrambled) -- Continuity\_count\_error (CC discontinuity / wrong increment / illegal duplicate) -- PMT\_error / PMT\_error\_2 (PMT repetition > 0.5 s, scrambled) -- PID\_error (a referenced PID not seen within a user-defined window) - -##### Priority 2 (recommended continuous monitoring) - -- Transport\_error (TEI bit set) -- CRC\_error (PAT/PMT/CAT/NIT/EIT/BAT/SDT CRC) -- PCR\_repetition\_error (PCR interval > 40 ms) -- PCR\_discontinuity\_indicator\_error (> 100 ms jump w/o discontinuity\_indicator) -- PCR\_accuracy\_error (PCR drift outside +/- 500 ns) -- PTS\_error (PTS repetition interval > 700 ms) -- CAT\_error (CAT required when scrambling present) - -##### Priority 3 (application dependent; mostly opaque-lane only) - -- NIT/SDT/EIT/TDT/RST\_error, SI\_repetition\_error, unreferenced\_PID - -#### Surfacing - -- Per-check counters + last-event timestamp, per PID where applicable. -- Aggregate per-stream "health" suitable for an operator dashboard (green/amber/red per - priority, akin to an SRT/Zixi stats panel). -- Exposed through the existing stats API so relay/CLI consumers can read it; no new - bespoke transport. - -#### Out of scope (for now) - -- Remediation (FEC, 2022-7) - separate egress work. -- Full DVB SI semantic validation beyond presence/repetition/CRC. -- A GUI; this is the metrics source, not a dashboard. - -#### Open questions for discussion - -1. Does monitoring live in a dedicated `moq-ts`/`moq-monitor` crate, or inside the - ingest/egress crates that already touch TS? -2. Which subset is MVP, P1 + the PCR/CC/PTS parts of P2? -3. Configuration surface (thresholds, which PIDs, sampling) - CLI flags vs config file. - A private reference implementation of these checks exists and can inform thresholds and - edge cases; happy to share it as background (not as a code drop). +1. Where the checks live: a new crate, or beside the TS container in + `rs/moq-mux/src/container/ts`. +2. The MVP subset: P1 plus the PCR, CC, and PTS parts of P2 is the issue's + suggestion. +3. The configuration surface (thresholds, PIDs, sampling) and how the + counters map onto `moq-stats` tracks. +4. Whether the opaque whole-mux lane is wanted at all; without it, P3 is out. ## Closes - [#1838](https://github.com/moq-dev/moq/issues/1838) - close this issue when the quest finishes + +## Related + +- [TS import liveness](/quest/m1/3489-ts-import-stream-liveness.md) - the stream-stall case this model names `PID_error` diff --git a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md index aa313e8dba..d72e102a55 100644 --- a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md +++ b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md @@ -1,79 +1,42 @@ -# [XL] moq-video: carry PipeWire DMA-BUFs safely into the Vulkan renderer +# [M] moq-video: validate PipeWire DMA-BUFs into the Vulkan renderer ## Goal -Implement and verify the behavior tracked in [#2819](https://github.com/moq-dev/moq/issues/2819) -within the issue's stated scope and boundaries. +The Linux zero-copy spine from [#2819](https://github.com/moq-dev/moq/issues/2819), +`PipeWire DMA-BUF -> Surface::DmaBuf -> Vulkan import -> render shader`, is +proven on real hardware, and a V4L2 camera feeds `Surface::DmaBuf` too. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -#### Goal - -Complete the Linux zero-copy surface spine from #2481 as one producer-to-consumer series: - -`PipeWire DMA-BUF -> moq_video::Surface::DmaBuf -> Vulkan import -> render shader` - -This is the first Linux producer and consumer for the public DMA-BUF surface contract. VAAPI encode/decode and V4L2 M2M can reuse the same contract afterward. - -#### Required lifetime invariant - -Duplicating a DMA-BUF fd preserves the allocation, not the pixels. Returning a dequeued PipeWire buffer lets the compositor overwrite that allocation while a queued frame or GPU import still reads it. - -The PipeWire producer must therefore retain the dequeued buffer until the last `DmaBuf` clone drops, then return it to the stream on the PipeWire loop thread. The renderer must retain its clone until the GPU submission completes. An fd-only wrapper is racy and is not acceptable. - -#### Phase 1: surface and producer - -- \[x] Add the non-default `dmabuf` feature, enabled by `pipewire`, `vaapi`, and `render`. -- \[x] Add `Surface::DmaBuf` with a concrete public payload, typed DRM format, modifier, dimensions, plane offsets/strides, and mint-on-access `export() -> OwnedFd`. -- \[x] Keep backend export/download behavior private so no public implementable trait freezes backend internals. -- \[x] Negotiate DMA-BUF plus shared-memory fallback in PipeWire, preferring packed RGB for the first complete Vulkan path and retaining NV12 support. -- \[x] Retain the dequeued PipeWire buffer through the surface lifetime and return it on the loop thread. -- \[x] Keep the universal I420 fallback for linear NV12 and RGB DMA-BUFs. Reject non-linear CPU mapping instead of interpreting tiled memory as rows. -- \[x] Add unit coverage for descriptors, NV12 stride removal, buffer negotiation, and return-on-last-drop. - -#### Phase 2: renderer consumer - -- \[x] Add a Linux Vulkan DMA-BUF importer behind the non-default render feature. -- \[x] Import single-plane XRGB/ARGB/XBGR/ABGR through wgpu 30's `VULKAN_EXTERNAL_MEMORY_DMA_BUF` HAL path. -- \[x] Retain the producer surface until the GPU submission completes, so PipeWire cannot overwrite in-flight pixels. -- \[x] Preserve the renderer's CPU fallback and three-strike fast-path retirement. -- \[x] Document the wgpu device feature required for DMA-BUF import. A custom device-creation helper is unnecessary with wgpu 30. -- \[ ] Import multi-plane NV12 with explicit DRM modifier plane layouts. -- \[ ] Copy imported NV12 Y/UV planes into wgpu-sampleable R8/RG8 textures without touching the CPU. -- \[ ] Handle unsupported Intel tiling with a VAAPI VPP re-tile path. `Processor` blits exist (moq-vaapi 0.1.0); this item is the renderer using one when a modifier will not import. - -#### Validation gates - -- \[x] macOS `moq-video --all-features` compile and tests remain green. -- \[x] The wgpu 30 packed DMA-BUF HAL import compiles in an isolated Vulkan-enabled API check. -- \[x] Linux renderer-only cross-build (`x86_64-unknown-linux-gnu`, `--features render`). -- \[ ] Native Linux `--features pipewire,render` compile and tests. -- \[ ] Shared-memory PipeWire fallback still captures. -- \[ ] Intel or AMD desktop: packed DMA-BUF capture renders with zero CPU download. -- \[ ] Modifier mismatch exercises VPP re-tiling on hardware that needs it. -- \[ ] Holding several frames cannot produce torn/reused content or exhaust the pool permanently. -- \[ ] Hardware tests ship ignored with a reason where CI lacks the device. - -The first packed-RGB vertical slice is implemented locally. Native Linux validation is still required before opening a PR, and the zero-copy tracker item stays open until the real hardware gates pass. +Built already: the `dmabuf` feature and `Surface::DmaBuf`, PipeWire DMA-BUF +negotiation with the shared-memory fallback, the dequeued buffer retained +until the last clone drops (#2839), packed RGB import in +`rs/moq-video/src/render/dmabuf.rs`, and NV12 import (#3331), which aliases +the buffer as an `R8` luma and an `RG8` chroma texture with no copy. VA-API +encode takes a `Surface::DmaBuf` directly. + +What remains: + +- Hardware gates, as ignored tests with a reason where CI lacks the device: + native Linux `--features pipewire,render` tests; the shared-memory fallback + still captures; packed and NV12 DMA-BUF capture renders with zero CPU + download on an Intel or AMD desktop; holding several frames never shows + reused content or exhausts the PipeWire pool for good. +- Modifier mismatch: `render/dmabuf.rs` downloads a buffer whose modifier the + driver will not import, and re-tiles nothing. A VA-API VPP re-tile is only + worth adding if a measured capture source lands on such a modifier; record + the modifiers seen and decide. +- V4L2 capture still converts to I420 on the CPU. Export its buffers with + `VIDIOC_EXPBUF` (the ioctl is in `moq-v4l`, unused) as a `Surface::DmaBuf` + so a camera reaches VA-API or NVENC without a copy. Refs #2481, #1837. -This also covers the Linux zero-copy capture input gap: V4L2 and PipeWire -convert to I420 on the CPU today (YUYV, BGRA), so V4L2 `VIDIOC_EXPBUF` export -and PipeWire DMA-BUF negotiation are what feed a `Surface::DmaBuf` straight -into VAAPI or NVENC. - ## Closes - [#2819](https://github.com/moq-dev/moq/issues/2819) - close this issue when the quest finishes ## Related -- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate memory blocks from a camera, which is the capture offer rather than this renderer import -- [#2893: video: validate PipeWire DMA-BUF capture on KDE hardware](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - related open work +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate memory blocks from a camera, the capture offer rather than this import +- [#2893: video: validate PipeWire DMA-BUF capture on KDE hardware](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - the KDE portal capture that timed out diff --git a/quest/m2/703-experimental-webgpu-renderer.md b/quest/m2/703-experimental-webgpu-renderer.md deleted file mode 100644 index cf78cdd882..0000000000 --- a/quest/m2/703-experimental-webgpu-renderer.md +++ /dev/null @@ -1,26 +0,0 @@ -# [M] Experimental WebGPU renderer - -## Goal - -Implement and verify the behavior tracked in [#703](https://github.com/moq-dev/moq/issues/703) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -**WARNING** This is a pre-mature optimization or gimmick at best. - -We currently use [Canvas2D](https://github.com/kixelated/moq/blob/ff7cf92679e16c1d18ca5862aa4c7f73417e3c36/js/hang/src/watch/video/renderer.ts#L97) to render individual frames. This is pretty basic but works. - -All major browsers support WebGPU now. It has a [copyExternalImageToTexture](https://developer.mozilla.org/en-US/docs/Web/API/GPUQueue/copyExternalImageToTexture) method that apparently copies a `VideoFrame` to a renderable texture. This apparently avoids a copy so it might be faster than Canvas2D but probably not. - -The main benefit of WebGPU is being able to do *other* stuff, like run AI models on pixel data without copying to the CPU. Or rendering a person's face on a teapot. Or using shaders for gimmicky effects. None of this is really generic enough for a MoQ library but maybe somebody wants to have some fun. - -## Closes - -- [#703](https://github.com/moq-dev/moq/issues/703) - close this issue when the quest finishes diff --git a/quest/m2/823-svc-support.md b/quest/m2/823-svc-support.md deleted file mode 100644 index 7445ef6016..0000000000 --- a/quest/m2/823-svc-support.md +++ /dev/null @@ -1,22 +0,0 @@ -# [M] SVC support? - -## Goal - -Implement and verify the behavior tracked in [#823](https://github.com/moq-dev/moq/issues/823) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -WebCodecs supports SVC, but somebody should actually test it out. - -If it works, we could add a field to the catalog indicating `layer`. The media download logic will get more complicated but it should be possible to support. - -## Closes - -- [#823](https://github.com/moq-dev/moq/issues/823) - close this issue when the quest finishes diff --git a/quest/m2/README.md b/quest/m2/README.md index 80fa09dd1b..ff38a25780 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -13,13 +13,15 @@ feature. A study may end with a measured no-go. Work gated on hardware, a partner, or a provider waits in [m3](/quest/m3/README.md); work waiting on an upstream release waits in [m4](/quest/m4/README.md). -## Quests +## Required - [AV1 metadata separation](/quest/m2/av1-metadata.md) - retain metadata OBUs inline while evaluating separate delivery - [SEI separation](/quest/m2/sei/README.md) - retain inline SEI until measured savings or a metadata-only consumer justify a split - [Catalog track identity](/quest/m2/catalog-tracks.md) - compare immutable track definitions with explicit catalog-to-group binding - [Archive recovery listing](/quest/m2/archive-recovery-listing.md) - a resumed DVR lists what changed since its checkpoint, not every stored group - [Archive backward timestamps](/quest/m2/archive-backward-timestamps.md) - a resumed recording refuses a track whose timestamps go backward +- [moq play drain tail](/quest/m2/play-drain-tail.md) - retired renditions and finite tracks play their last 10 ms of audio +- [Relay io_uring packages](/quest/m2/relay-io-uring-package.md) - Linux relay packages ship io_uring once the ring is on par with tokio - [Mobile ownership](/quest/m2/mobile-ownership.md) - decide whether Rust or platform code owns mobile capture, codecs, and rendering - [iOS capture](/quest/m2/mobile-capture-ios.md) - camera and screen capture if the mobile ownership decision selects Rust - [Android capture](/quest/m2/mobile-capture-android.md) - NDK/JNI capture using the existing codecs if mobile ownership selects Rust @@ -29,14 +31,12 @@ upstream release waits in [m4](/quest/m4/README.md). - [Audio loss recovery](/quest/m2/audio-loss-recovery.md) - prove a useful Opus recovery policy before exposing another option - [Opus implementation](/quest/m2/audio-opus-backend.md) - compare current codec quality, CPU, and optional build costs - [Latency ledger](/quest/m2/latency-ledger.md) - a session reports where its end-to-end audio delay went, stage by stage -- [JS discontinuity](/quest/m2/js-discontinuity.md) - JS names its timeline break `discontinuity()` like Rust, so `cut` means the same group close in both +- [JS discontinuity](/quest/m2/js-discontinuity.md) - on dev, JS `discontinuity()` without an end writes no cadence-estimated end, like Rust - [Synced data playback](/quest/m2/watch-data-sync.md) - js/watch releases JSON and binary payloads on the media playhead, and a slow data track holds media back - [Media Foundation decode](/quest/m2/audio-decode-mediafoundation.md) - Windows decodes HE-AAC, multichannel AAC, and what else the MFTs offer - [Media Foundation encode](/quest/m2/audio-encode-mediafoundation.md) - Windows encodes AAC-LC - [MediaCodec decode](/quest/m2/audio-decode-mediacodec.md) - Android decodes HE-AAC, multichannel AAC, and what else the device offers - [MediaCodec encode](/quest/m2/audio-encode-mediacodec.md) - Android encodes AAC-LC -- [AAC encode refusal](/quest/m2/aac-encode-refusal.md) - AAC config encode refuses channel counts it cannot name, on dev -- [OBS channel layouts](/quest/m2/obs-wave-layout.md) - the OBS source maps channel counts to the WAVE default layouts, like moq-audio - [Video codec coverage](/quest/m2/video-codec-coverage.md) - prioritize remaining native AV1 and portable decoder gaps - [#2147](/quest/m2/2147-moq-video-10-bit-hevc-and-av1-support-in-the-nvidia-codec.md) - moq-video: 10-bit HEVC and AV1 support in the NVIDIA codec path - [NVENC buffer pool](/quest/m2/nvenc-pool.md) - NVENC reuses input and output buffers instead of allocating per frame, if a benchmark shows it wins @@ -44,16 +44,15 @@ upstream release waits in [m4](/quest/m4/README.md). - [Direct3D11 render import](/quest/m2/render-d3d11.md) - Windows presents without downloading every frame to system memory - [Intra-refresh GOPs](/quest/m2/intra-refresh/README.md) - video with periodic intra refresh publishes, imports, and tunes in cleanly with one group per sweep and a catalog `warmup` - [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - I420 and NV12 cameras that deliver one memory block per plane -- [#2819](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - moq-video: carry PipeWire DMA-BUFs safely into the Vulkan renderer +- [#2819](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - moq-video: validate PipeWire DMA-BUFs into the Vulkan renderer on hardware, and export V4L2 buffers as DMA-BUFs - [Unreal prototype](/quest/m2/unreal.md) - a UE5 module on the C++ package with exceptions disabled, rendering a subscribed broadcast to a texture - [Unity prototype](/quest/m2/unity.md) - the C# package under IL2CPP, playing subscribed audio - [C# through moq-ffi](/quest/m2/cs/README.md) - generated C# over moq-ffi as a NuGet package with native runtimes - [vcpkg registry](/quest/m2/cpp-vcpkg.md) - a registry we own serves the prebuilt package to `vcpkg` manifests - [Conan remote](/quest/m2/cpp-conan.md) - a remote we own serves the same tarball to `conan install` -- [Compressed tracks](/quest/m2/flate/README.md) - any track compresses per group from every language, not only the JSON modes +- [Compressed tracks](/quest/m2/flate/README.md) - the hand-written binding wrappers expose flate tracks - [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 @@ -61,7 +60,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [QUIC FEC](/quest/m2/quic-fec.md) - a measured verdict on transport-level FEC vs retransmission - [Google BBR comparison](/quest/m2/quic-bbr-google.md) - measure growth detection and precautionary probing after the correctness fixes - [WHEP ABR](/quest/m2/whep-abr.md) - a WHEP viewer switches renditions from its own congestion feedback -- [Natural media drains](/quest/m2/quic-bbr-app-limited.md) - whether bounded drain credit avoids ProbeRTT deadline interference +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - whether bounded drain credit avoids ProbeRTT deadline interference - [Discover media headroom](/quest/m2/quic-probe.md) - test useful-media pacing before adding redundant probe traffic - [L4S on the backbone](/quest/m2/quic-ecn.md) - an ECT(1) option in the fork, an `ecn` config knob, and a dualpi2 measurement - [Careful resume on reconnect](/quest/m2/quic-careful-resume.md) - a redial starts at the previous connection's rate @@ -74,21 +73,18 @@ upstream release waits in [m4](/quest/m4/README.md). - [Send buffer pools](/quest/m2/quic-buffer-pool.md) - whether pooled send buffers beat Bytes in the stream send path - [AF_XDP UDP path](/quest/m2/af-xdp.md) - the kernel-bypass verdict on today's virtio hosts that gates DPDK - [GOP overhead](/quest/m2/gop-overhead.md) - price the I-frames a short GOP pays for, deciding whether a long GOP plus a keyframe request is worth designing -- [#703](/quest/m2/703-experimental-webgpu-renderer.md) - Experimental WebGPU renderer -- [#823](/quest/m2/823-svc-support.md) - SVC support? -- [#1838](/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md) - TR 101 290 monitoring: requirements (broadcast/contribution health metrics) +- [#1838](/quest/m2/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md) - plan TR 101 290 stream-health monitoring into implementation quests - [Teleoperation](/quest/m2/teleop/README.md) - MoQ carries robot video down and control up on one session as a library capability - [SIP media stack](/quest/m2/sip-stack.md) - terminate one inbound SIP audio call leg and expose it as Opus frames - [Carrier voice](/quest/m2/carrier-voice/README.md) - determine whether MoQ should be the call fabric for programmable carrier voice - [LiveKit WebRTC bridge](/quest/m2/livekit-webrtc-bridge.md) - a go/no-go verdict, backed by a spike, on per-track LiveKit-to-MoQ bridging -- [Expired token error](/quest/m2/auth-expired-error.md) - an expired token reports `Error::Expired`, not `Unauthorized`, in Rust, JS, and the bindings - [Common Access Tokens](/quest/m2/cat/README.md) - a moq-transport client presents a CAT in SETUP and `moq auth serve` admits it with the scope its `moqt` claim names - [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - run exact source snapshots on accessible Linux and device hosts with retrievable debug evidence - [Media QA on other engines](/quest/m2/browser-media-qa-engines.md) - the media harness measures a Firefox or WebKit player over the fallback and names what each engine lacks +- [Firefox 155 WebTransport](/quest/m2/firefox-155-webtransport.md) - Firefox negotiates the version by subprotocol, and the other new WebTransport features stay unused on purpose - [Windows capture parity](/quest/m2/capture-windows.md) - system audio and screen cursor capture with a settled app-capture policy - [Linux capture parity](/quest/m2/capture-linux.md) - Wayland window/system-audio capture with explicit display-selection and app-capture limits - [Plan capture ergonomics](/quest/m2/capture-ergonomics.md) - scope independent crop and audio mixing quests -- [Capture clock source](/quest/m2/capture-clock-source.md) - capture publishers stamp on the catalog's clock, with no separate clock to pass, on dev - [Audio capture time](/quest/m2/audio-capture-time.md) - native audio stamps a buffer's capture instant, not when the driver reads it - [X11 capture transport](/quest/m2/x11-capture-shm.md) - move X11 capture to shared memory and RandR events instead of a per-frame socket copy - [Capture frame buffers](/quest/m2/capture-frame-buffers.md) - stop rebuilding a full-frame buffer every tick in the X11 and Windows backends diff --git a/quest/m2/aac-encode-refusal.md b/quest/m2/aac-encode-refusal.md deleted file mode 100644 index 4f7a7371bf..0000000000 --- a/quest/m2/aac-encode-refusal.md +++ /dev/null @@ -1,22 +0,0 @@ -# [S] AAC encode refuses a channel count it cannot name - -## Goal - -Writing an AudioSpecificConfig for a channel count that no AAC -channelConfiguration names is an error, not a stereo config with a warning, -in `moq_mux::codec::aac::Config::encode`, as `@moq/hang`'s -`audioSpecificConfig` already refuses since #4119. This mirrors the parse -side, which since #4093 refuses reserved values instead of guessing stereo. - -## Plan - -`Config::encode` becomes fallible, a published API break, so this targets -`dev`. Counts with a PCE-free configuration map as today. Refuse the others, -matching JS; writing channelConfiguration 0 with a PCE derived from the layout -is a later additive change in both languages. Test every count from 1 to 8 and -one beyond. - -## Related - -- [AAC PCE](https://github.com/moq-dev/moq/pull/4093) - the parse half -- [Layout](/quest/m1/audio-codecs/layout.md) - the layout a PCE would be derived from diff --git a/quest/m2/audio-decode-mediafoundation.md b/quest/m2/audio-decode-mediafoundation.md index 20a2518458..6fae6be4fa 100644 --- a/quest/m2/audio-decode-mediafoundation.md +++ b/quest/m2/audio-decode-mediafoundation.md @@ -20,7 +20,7 @@ ones. Behind the decode seam as the first candidate on `target_os = not advertised on that host, not an error at decode time. - Fixtures and layout-order tests as in the AudioToolbox quest. - Verification runs on a Windows host; the per-PR CI only compiles the - platform code, and `just rs windows` runs nightly. + platform code (`just rs windows`). ## Required diff --git a/quest/m2/audio-loss-recovery.md b/quest/m2/audio-loss-recovery.md index 33480c6391..9be8b40dc0 100644 --- a/quest/m2/audio-loss-recovery.md +++ b/quest/m2/audio-loss-recovery.md @@ -25,4 +25,4 @@ audio settings. Existing wire compatibility must be demonstrated. ## Related -- [Audio quality](/quest/m1/audio-quality-harness/README.md) - quality and latency measurements +- [Audio quality](/quest/m0/audio-quality-harness/README.md) - quality and latency measurements diff --git a/quest/m2/audio-opus-backend.md b/quest/m2/audio-opus-backend.md index 3f1e682071..a981f0e007 100644 --- a/quest/m2/audio-opus-backend.md +++ b/quest/m2/audio-opus-backend.md @@ -24,4 +24,4 @@ before a separately scoped backend implementation. ## Related -- [Audio quality](/quest/m1/audio-quality-harness/README.md) - shared measurement infrastructure +- [Audio quality](/quest/m0/audio-quality-harness/README.md) - shared measurement infrastructure diff --git a/quest/m2/capture-clock-source.md b/quest/m2/capture-clock-source.md deleted file mode 100644 index a4979cf4fa..0000000000 --- a/quest/m2/capture-clock-source.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Capture publishers read the catalog's clock - -## Goal - -`moq_video::encode::publish_capture` and `moq_audio::encode::Control` stamp -on the clock their catalog advertises, with no separate clock to pass. Today -each takes its own `moq_mux::Clock`, and `CaptureOptions::default()` builds -a fresh one, so a caller relying on the default publishes audio against a -mapping the catalog never advertised. `moq import capture` passes -`catalog.clock()` to both, which is the only correct value. - -## Plan - -Drop the `clock` parameter from video `publish_capture` and the `clock` field -from `CaptureOptions`, reading `catalog.clock()` instead. Update moq-cli and -any binding that forwards a clock. The clock fixtures in both crates already -pass the catalog's clock, so they keep grading the same path. - -Public API: breaking in published `moq-video` and `moq-audio`, so it targets -`dev`. Wire: none. diff --git a/quest/m2/carrier-voice/README.md b/quest/m2/carrier-voice/README.md index 92799763de..4be5cad6d1 100644 --- a/quest/m2/carrier-voice/README.md +++ b/quest/m2/carrier-voice/README.md @@ -36,7 +36,7 @@ bespoke media fork for each service. compliance outside the experiment. The SIP adapter is the boundary to that world. -## Quests +## Required - [Call fabric protocol](/quest/m2/carrier-voice/protocol.md) - versioned namespaces, roles, state transitions, authorization, and both topologies diff --git a/quest/m2/cat/README.md b/quest/m2/cat/README.md index 8460cd1520..d68c4ec7d5 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -41,14 +41,13 @@ Boundaries decided while planning: ## Plan -Order: the wire first so a token reaches the auth server, which is the -standalone [Setup token](/quest/m1/setup-token.md) quest; verification; -then our clients present one. Everything rides `moq_auth::Request` and -`moq auth serve`, which shipped on dev. The JWT types sit at the crate root; -the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling -module rather than a set of prefixed names. +Order: the SETUP option already reaches the auth server as +`moq_auth::Request.token`; verification comes first, then our clients present +one. Everything rides `moq_auth::Request` and +`moq auth serve`. The JWT types stay at the crate root, so the published +`moq-auth` names do not break; `cat` is an additive module beside them. -## Quests +## Required - [Verify](/quest/m2/cat/verify.md) - `moq_auth::cat` turns a CAT into a grant and `moq auth serve` admits one; `moq auth sign|verify` mint and @@ -56,11 +55,6 @@ module rather than a set of prefixed names. - [Present](/quest/m2/cat/present.md) - a CAT is one kind of configured token, riding the SETUP option the in-band token quest already writes -## Required - -- [Setup token](/quest/m1/setup-token.md) - the SETUP option reaches - `moq_auth::Request` as `token` - ## Related - [In-band auth](/quest/m1/auth/README.md) - credentials presented after diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index cb318d20e1..8fcd518586 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -19,23 +19,27 @@ the option fails loud instead of dropping the credential. keeps taking a JWT; `--connect-cat ` (and `MOQ_CONNECT_CAT`) adds a CAT. One CAT per connection: it is the connection credential, so a configured CAT takes the setup option and the JWT that would have gone - there rides its AUTH stream instead. + there rides its AUTH stream instead, never the URL. `moq auth serve` + refuses a SETUP token beside a different `?jwt=` (see [Token in + band](/quest/m1/auth/token-in-band.md)), so a JWT on the URL would refuse the + CAT's connect; on a session without AUTH the JWT is simply absent, like + any extra token. - A CAT with any offered version that lacks the setup option (every lite version) fails `init` with `Unsupported` naming the token; `js/net` rejects the same way before dialing. - `moq` CLI publish and subscribe get the flag through the shared connect config; `doc/bin/cli.md` and `doc/lib/rs/moq-net.md` gain it. -- Tests: the server request sees kind `0x01` and the bytes on every draft - in Rust, JS, and across; a JWT still goes to the URL or AUTH stream when - a CAT holds the option; a lite offer with a CAT refuses at init; end to - end against `moq auth serve` with a CAT from `moq auth sign --format cat`. +- Tests: a Rust server's `Handshake::token()` sees kind `0x01` and the + bytes on every draft that carries the setup option, from both a Rust and a JS client (a JS server is out + of scope: #4278 added no JS accept-side API, since nothing in `js/net` authorizes an IETF session); a JWT rides its + AUTH stream and not the URL when a CAT holds the option; a lite offer with + a CAT refuses at init; end to end against `moq auth serve` with a CAT from + `moq auth sign --format cat`. Public API: additive on `moq-tokio` and `js/net`. Wire: none. ## Required -- [Setup token](/quest/m1/setup-token.md) - the server-side exposure - and the shared `setup::Token` - [Verify](/quest/m2/cat/verify.md) - the server that admits the token the end-to-end test presents - [Token in band](/quest/m1/auth/token-in-band.md) - the token diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index 29a03c3126..e606294f04 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -11,14 +11,16 @@ tokens. Every claim we do not evaluate refuses the token naming the claim. ## Plan -The JWT types (`Claims`, `Key`, `Jwk`, `KeyId`, `Algorithm`, the key set, -`authorize`) sit at the `moq_auth` root today. Move them under -`moq_auth::jwt` first, keeping their names, so `cat` is a sibling module -rather than a set of prefixed names; `@moq/auth` stays flat. +The JWT types (`Claims`, `Key`, `Jwk`, `KeyId`, `Algorithm`, `Scope`, the +key set) sit at the root of the published `moq-auth` 0.1.x, and they stay +there: `cat` is an additive module beside them, so this lands on `main`. +Moving them under `moq_auth::jwt` would break every published caller; if it +is still wanted, it is its own `dev` change, not part of this quest. Decided +in the 2026-09-28 quest audit. `@moq/auth` stays flat. - Crates: `coset` for COSE and CWT claims, `ciborium` for CBOR; HMAC through the `aws-lc-rs` the crate already links, ES256 through `p256`. Keys reuse - `moq_auth::jwt::Key` files: a JWK with `alg` maps to the COSE algorithm + `moq_auth::Key` files: a JWK with `alg` maps to the COSE algorithm (`HS256` to HMAC 256/256, `ES256` to -7, `EdDSA` to -8, `RS256` to -257); a JWK whose algorithm has no COSE mapping is refused at load naming it. - `cat::Claims { issuer, audience, subject, expires, not_before, issued, @@ -46,7 +48,7 @@ rather than a set of prefixed names; `@moq/auth` stays flat. than widening a fetch-only token into a live subscription. `ClientSetup` and `ServerSetup` add nothing. The namespace fields become one `moq_pattern::Pattern` segment each, the mapping `rs/moq-pattern` documents under "CAT / C4M", relative - to the dialed path exactly as `jwt::Claims::root` is: `Exact(f)` is the + to the dialed path exactly as the JWT `Claims::root` is: `Exact(f)` is the literal segment, `Prefix(f)` is `f*`, `Suffix(f)` is `*f`, a named namespace without `exact_depth` appends `/**`, and an absent namespace is bare `**` with nothing appended. A field value containing `/` or `*` @@ -78,12 +80,10 @@ rather than a set of prefixed names; `@moq/auth` stays flat. vectors round-tripped both ways; expiry and not-before; the grant produced from every c4m-01 example; serve admitting a CAT over a real moq-transport session on every supported draft and refusing an unknown - token kind, a bad MAC, and a token plus `jwt`; the CLI round trip. + token kind, a bad MAC, and a CAT beside any `jwt` (a CAT is type `0x01` + and a `jwt` is a JWT, so equal bytes are still two credentials; only a + type-0 SETUP token equal to `jwt` is admitted once, per [Token in + band](/quest/m1/auth/token-in-band.md)); the CLI round trip. Public API: `moq_auth::cat` new, `moq auth serve` and `moq auth sign|verify` gain flags. Wire: none. - -## Required - -- [Setup token](/quest/m1/setup-token.md) - the token reaches the - server's request diff --git a/quest/m2/cpp-conan.md b/quest/m2/cpp-conan.md index a4d1dfe8d4..19e36c2ac2 100644 --- a/quest/m2/cpp-conan.md +++ b/quest/m2/cpp-conan.md @@ -2,16 +2,17 @@ ## Goal -A consumer adds the moq Conan remote, requires `moq/`, and gets the +A consumer adds the moq Conan remote, requires `moq-cpp/`, and gets the prebuilt package for their `os`, `arch`, and `compiler` without a Rust toolchain or the bindgen fork. A fresh consumer project installs it in CI on Windows, macOS, and Linux. ## Plan -- A `moq` recipe on a moq-dev remote (Artifactory or a GitHub-hosted `conan` - index) that packages the prebuilt release tarball per setting and exports - the CMake target from `package_info`. +- A `moq-cpp` recipe, named after the package, on a moq-dev remote + (Artifactory or a GitHub-hosted `conan` index) that packages the prebuilt + release tarball per setting and exports the `moq::cpp` CMake target from + `package_info`. - The recipe reads the release manifest the vcpkg quest introduced, so one release bumps both recipes; `release-cpp.yml` publishes to the remote after the tarballs land. diff --git a/quest/m2/cpp-vcpkg.md b/quest/m2/cpp-vcpkg.md index 40b98e73fc..36ffc7427f 100644 --- a/quest/m2/cpp-vcpkg.md +++ b/quest/m2/cpp-vcpkg.md @@ -3,13 +3,14 @@ ## Goal A consumer adds `moq-dev/vcpkg-registry` to `vcpkg-configuration.json`, -depends on `moq`, and gets the prebuilt package for their triple without a +depends on `moq-cpp`, and gets the prebuilt package for their triple without a Rust toolchain or the bindgen fork. A fresh consumer project installs it in CI on Windows, macOS, and Linux. ## Plan -- `moq-dev/vcpkg-registry`: a git registry with a `moq` port whose portfile +- `moq-dev/vcpkg-registry`: a git registry with a `moq-cpp` port, named after + the package (`find_package(moq-cpp)`, target `moq::cpp`), whose portfile downloads the per-target release tarball from `release-cpp.yml` by version and hash, installs headers, the static library, and the CMake config, and declares `supports` for exactly the release matrix. Versioning follows the diff --git a/quest/m2/cs/README.md b/quest/m2/cs/README.md index 9c3e9822a0..69c17b0a8d 100644 --- a/quest/m2/cs/README.md +++ b/quest/m2/cs/README.md @@ -17,7 +17,7 @@ IL2CPP constraints Unity adds (static `MonoPInvokeCallback` trampolines, no dynamic loading) are measured in the next prototype rather than designed around up front. -## Quests +## Required - [Generator](/quest/m2/cs/generator.md) - uniffi-bindgen-cs on uniffi 0.32, pinned and generating `cs/ffi` in CI - [Package](/quest/m2/cs/package.md) - the `cs/moq` wrapper, NuGet package with native runtimes, interop client, and docs diff --git a/quest/m2/firefox-155-webtransport.md b/quest/m2/firefox-155-webtransport.md new file mode 100644 index 0000000000..dc8af1eb6d --- /dev/null +++ b/quest/m2/firefox-155-webtransport.md @@ -0,0 +1,47 @@ +# [XS] Firefox 155 negotiates the version by WebTransport subprotocol + +## Goal + +Firefox 155 reports the negotiated `protocol`, so a Firefox watcher lands on +the version the relay picks from `WT-Available-Protocols` (lite-06 today) +instead of the draft-14 SETUP fallback it used before, as Chrome 143+ does. +js/net's comments and types stop claiming native WebTransport lacks +`protocol`. + +## Plan + +- By hand against the in-tree relay, confirm Firefox 155 reports a non-empty + `protocol` and negotiates lite-06, and Firefox 153/154 still reach the + draft-14 SETUP path. Playwright Firefox has no WebTransport, so this cannot + run in CI (see [Media QA on other engines](/quest/m2/browser-media-qa-engines.md)). +- Delete the stale comment in `negotiate` (`js/net/src/connection/connect.ts`) + and the `@ts-expect-error` on `transport.protocol` in + `js/net/src/connection/accept.ts`, typing it the way `connect.ts` does if the + DOM lib still lacks the property. The empty-string and `undefined` fallback + stays for Firefox 153/154 and draft-14 peers. +- Fix any Firefox line in `doc/lib/js/index.md` or `doc/concept/transport.md` + this makes stale. + +Firefox 155 shipped five WebTransport features; decided 2026-09-26: + +- **Subprotocols** are the only one MoQ adopts in the browser, and js/net and + every Rust backend already speak them; this quest is the Firefox check. +- **Send groups** are native only. MoQ wants strict priority between + subscriptions (audio over video), and browser send groups are flat and + byte-fair, so js/net keeps the default group and its packed `sendOrder`. + moq-noq gains send groups in the + [scheduler](/quest/m1/quic/scheduler.md), and relays use one per broadcast on + cluster links in [Scope track priority](/quest/m1/track-priority-scope.md). + Pooling several publishers on one browser connection might want them later, + but sessions sharing the same content make that murky. +- **`datagrams.createWritable()`** is already feature-detected in js/net and + web-transport-wasm; nothing to do. +- **`draining`** is not used. Drain stays at the MoQ layer: GOAWAY carries a + redirect URI (and a timeout on moq-transport draft-17+; elsewhere the + deadline is sender-local) and works over qmux and WebSocket, while + `WT_DRAIN_SESSION` is advisory and carries neither (see + [drain](/quest/m1/drain/README.md)). +- **`exportKeyingMaterial()`** has no consumer: the exporter is per hop, so it + cannot key e2ee, which is end to end. Binding auth tokens to the TLS session + is the plausible future use; moq-noq already exposes the exporter, but + web-transport-moq does not. diff --git a/quest/m2/flate/README.md b/quest/m2/flate/README.md index 063f0f37c7..58936a70b3 100644 --- a/quest/m2/flate/README.md +++ b/quest/m2/flate/README.md @@ -2,30 +2,21 @@ ## Goal -Any track can be DEFLATE-compressed per group from every language, not only -the JSON modes. A native or C caller publishes and consumes a compressed track -of opaque frames the same way a Rust or browser caller does, and the bytes on -the wire are identical across all of them. +Opaque tracks, compressed per group or not, are published and consumed the +same way from every language, not only Rust and JS, and the bytes on the wire +are identical across all of them. ## Plan -`moq-flate` and `@moq/flate` today are a bare codec: `Encoder`/`Decoder` with -`frame(bytes) -> bytes` and a shared window the caller scopes to a group by -hand. Only `moq-json` composes them, so the bindings reach compression through -`compression: bool` on the JSON configs and nothing else. A telemetry, caption, -or sensor track of raw frames has no compressed form outside Rust and JS, and -even there the caller re-derives the group discipline from the crate docs. - -The line adds a track wrapper to the crate first, then binds that wrapper. The -codec objects stay as they are; the wrapper owns the per-group window so a -caller cannot desynchronize it. The wire format does not change: a wrapper -group is the raw sync-flushed stream the codec already emits, so a wrapped -producer interoperates with a hand-composed consumer and with `moq-json`. +`moq-flate` and `@moq/flate` absorb `moq-binary`'s snapshot and stream modes +in [moq-binary folds into moq-flate](/quest/m1/flate-binary.md), so the crate +already owns the per-group window a caller could otherwise desynchronize. The +track wrapper this line once planned was dropped for that reason. What +remains is reaching those tracks from the hand-written binding wrappers. No wire, catalog, or relay impact. Compression stays invisible to `moq-net`; a compressed track is announced, routed, and cached like any other. -## Quests +## Required -- [Track wrapper](/quest/m2/flate/track.md) - `moq-flate` and `@moq/flate` wrap a track so each group is one compression window without caller bookkeeping -- [Bindings](/quest/m2/flate/bindings.md) - moq-ffi and moq-c publish and subscribe compressed tracks, mirrored through every wrapper +- [Bindings](/quest/m2/flate/bindings.md) - the hand-written wrappers expose flate tracks diff --git a/quest/m2/flate/bindings.md b/quest/m2/flate/bindings.md index fae37cd217..ef28b41d31 100644 --- a/quest/m2/flate/bindings.md +++ b/quest/m2/flate/bindings.md @@ -2,55 +2,35 @@ ## Goal -moq-ffi and moq-c publish and subscribe a compressed track of opaque frames, -and every wrapper (Python, Swift, Kotlin, Go, Dart, C) reaches it. A track -written from C decodes in the browser with `@moq/flate` and vice versa. +The hand-written wrappers (Python, Swift, Kotlin, Go, Dart) expose flate +tracks, the snapshot and stream opaque tracks moq-ffi already generates, in +their own idiom beside the JSON entry. A track published from a wrapper +decodes in the browser with `@moq/flate` and vice versa. ## Plan -Bind the track wrapper, not the codec: the bindings' job is to make the group -discipline unrepresentable to misuse, and a bare `frame()` call across an FFI -boundary invites the desync the wrapper exists to prevent. - -moq-ffi, next to `json.rs` and named the same way: - -- `MoqBroadcastProducer::publish_flate(name, MoqFlateConfig) -> MoqFlateProducer` - with `append_group() -> MoqFlateGroupProducer`, `finish()`, `abort(code)`. -- `MoqFlateGroupProducer::write_frame(MoqFrame)`, `finish()`, `abort(code)`. - A failed write aborts the group and the handle refuses further writes. -- `MoqBroadcastConsumer::subscribe_flate(name, MoqFlateConfig) -> MoqFlateConsumer` - with `next_group() -> Option`, `cancel()`. -- `MoqFlateGroupConsumer::read_frame() -> Option`, `cancel()`. -- `MoqFlateConfig { level = 6, max_frame_size = 64 MiB }` as `#[uniffi(default)]` - literals, with the same drift test `json.rs` keeps against the crate defaults. - -Frames cross as `MoqFrame` so the transport timestamp survives, as on the raw -track API; only the payload is compressed. Explicit groups rather than a flat `append(bytes)` because the window resets -at the boundary and the caller chooses where that is; a helper that rolls -groups on a size or count budget can follow if a consumer asks. - -moq-c mirrors `moq_publish_json_*` and `moq_consume_json_*`: -`moq_publish_flate`, `moq_publish_flate_group`, `moq_publish_flate_frame`, -`moq_publish_flate_group_finish`, `moq_publish_flate_finish`, -`moq_consume_flate`, `moq_consume_flate_group`, `moq_consume_flate_frame`, -`moq_consume_flate_frame_free`, `moq_consume_flate_close`, with a -`moq_flate_config` struct. Follow the terminal-status callback contract for -the consume side and regenerate `moq.h`. - -Wrappers per the Cross-Package Sync table: the uniffi bindings regenerate; -`go/wrapper/moq/json.go`, `py/moq-rs/moq/{publish,subscribe}.py`, -`swift/Sources/Moq/Json.swift`, `kt/moq`'s `Json.kt` with its `Aliases.kt` -re-exports and `Flows.kt` extensions, and `dart/moq` each gain a hand-written -sibling. Document in `doc/lib/{c,py,swift,kt,go,dart}` beside the JSON entry. - -Tests: a moq-ffi round trip next to `json_snapshot_roundtrip`, a moq-c C -round trip in `src/test.rs`, and one cross-language check that a C-published -group decodes with the shared vector from the track quest. Run -`just test interop --all`. - -Public API impact: additive on moq-ffi, moq-c, and every wrapper; `main`. -Wire impact: none. +moq-ffi publishes opaque tracks today (`publish_binary_snapshot` and +`publish_binary_stream`, #4137), renamed after `flate` by +[moq-binary folds into moq-flate](/quest/m1/flate-binary.md). Only the +generated bindings reach them; no wrapper does. This quest binds the existing +track modes, not the bare codec: a `frame()` call across the FFI boundary +invites the window desync the track modes exist to prevent. + +- moq-ffi has no consume side for these tracks. Add it next to the JSON + consumers so each wrapper can read what it writes. +- Wrappers per the Cross-Package Sync table: `go/wrapper/json.go`, + `py/moq-rs/moq/{publish,subscribe}.py`, `swift/Sources/Moq/Json.swift`, + `kt/moq`'s `Json.kt` with its `Aliases.kt` re-exports, and + `dart/moq/lib/src/aliases.dart` each gain a flate sibling. If + [FFI shape](/quest/m1/ffi-shape/README.md) has landed, follow its `flate` + namespace instead. +- Document in `doc/lib/{py,swift,kt,go,dart}` beside the JSON entry. +- Tests: a round trip in each wrapper that has tests, and one cross-language + check that a wrapper-published group decodes with `@moq/flate`. Run + `just test interop --all`. + +Public API: additive on moq-ffi and every wrapper. Wire: none. ## Required -- [Track wrapper](/quest/m2/flate/track.md) - the surface being bound +- [moq-binary folds into moq-flate](/quest/m1/flate-binary.md) - the flate snapshot and stream tracks and their moq-ffi names diff --git a/quest/m2/flate/track.md b/quest/m2/flate/track.md deleted file mode 100644 index cec5104a6b..0000000000 --- a/quest/m2/flate/track.md +++ /dev/null @@ -1,55 +0,0 @@ -# [M] Flate track wrapper - -## Goal - -`moq_flate::track::{Producer, Consumer}` and the matching `@moq/flate` classes -wrap a `moq-net` track so every group is one compression window. A caller -writes and reads plain frames; the wrapper compresses, decompresses, and -resets the window at each group boundary. A Rust producer and a browser -consumer (and the reverse) round-trip byte-identical frames. - -## Plan - -Mirror the `moq-json` layering: the codec layer stays for callers that own -their own groups, and the new track layer owns a `moq_net::track::Producer` -or `Subscriber` plus one codec per live group. - -Shape, matching `moq-net` names so the wrapper reads like the track it wraps: - -- `track::Producer::new(track, Config)`, `append_group() -> group::Producer`, - `create_group(sequence)`, `finish()`, `abort(code)`, `consume()`. -- `group::Producer`: `write_frame(timestamp, &[u8])`, `finish()`, - `abort(code)`. Holds the `Encoder`; dropping it without `finish` aborts, - per the refcount idiom. A write that fails after the encoder advanced (an - oversized frame, a closed group) leaves the window ahead of the consumer, - so it is terminal: the group aborts and the handle refuses further writes. -- `track::Consumer::new(subscriber, Config)`, `next_group() -> group::Consumer` - (plus `recv_group` if the raw consumer distinguishes them), `update(...)`. -- `group::Consumer`: `read_frame() -> Option`, the transport - timestamp intact and the payload inflated. Holds the `Decoder`. -- `Config { level, max_frame_size }` with the crate defaults; one struct shared - by both sides, the consumer reading only `max_frame_size`. A frame past the - cap is an error that aborts the group, never a truncated frame. - -No datagrams: a datagram has no window to share, so `append_datagram` is not -on the wrapper. Timestamps pass through untouched: `moq-net` frames carry one -on both sides, and a timed opaque track (telemetry, captions) must round-trip -as the same track. Only the payload is compressed. - -Decide whether `moq-json`'s `stream` and `window` modes should move onto the -group wrapper. They already keep one encoder per group, so it is likely a -deletion, but `stream::Error::Desync` exists because the codec layer can -encode without writing; the wrapper closes that gap by making a failed write -terminal instead. Take the deletion if it is clean, otherwise leave `moq-json` -alone and note why. - -Verify with a shared test vector: a fixed frame sequence compressed by Rust, -checked into both test suites, and decoded by the JS wrapper, with the reverse -direction generated by JS. The existing codec tests stay. - -Public API impact: additive on `moq-flate` and `@moq/flate`; lands on `main`. -Wire impact: none. - -## Related - -- [Bindings](/quest/m2/flate/bindings.md) - binds this wrapper diff --git a/quest/m2/intra-refresh/README.md b/quest/m2/intra-refresh/README.md index 15219af33b..3bfb94cd11 100644 --- a/quest/m2/intra-refresh/README.md +++ b/quest/m2/intra-refresh/README.md @@ -36,7 +36,7 @@ Decisions the quests share: Backends without the knob refuse refresh mode; NVENC and V4L2 get it now, Media Foundation and MediaCodec are follow-ups. -## Quests +## Required - [Consumer warmup](/quest/m2/intra-refresh/consumer-warmup.md) - JS and Rust viewers join `warmup` earlier and withhold display until recovery, except at a true IDR - [H.264 import](/quest/m2/intra-refresh/h264-import.md) - the splitter keeps `recovery_frame_cnt` and import publishes `warmup` from it @@ -44,7 +44,7 @@ Decisions the quests share: - [Encode config](/quest/m2/intra-refresh/encode-config.md) - refresh mode extends the settled GOP contract; the producer cuts groups per sweep and publishes `warmup` - [NVENC refresh](/quest/m2/intra-refresh/nvenc-refresh.md) - the NVENC backend encodes refresh mode for H.264 and HEVC - [V4L2 refresh](/quest/m2/intra-refresh/v4l2-refresh.md) - the V4L2 backend encodes refresh mode -- [Bindings](/quest/m2/intra-refresh/bindings.md) - ffi, moq-c, and every wrapper expose the `Gop` enum +- [Bindings](/quest/m2/intra-refresh/bindings.md) - moq-ffi and every wrapper expose refresh mode, additive on the ffi-shape `Gop` enum - [Export sync flags](/quest/m2/intra-refresh/export-sync-flags.md) - fmp4, MKV, and HLS stop advertising a refresh group start as a sync sample ## Related diff --git a/quest/m2/intra-refresh/bindings.md b/quest/m2/intra-refresh/bindings.md index a9ee769898..78bbc95863 100644 --- a/quest/m2/intra-refresh/bindings.md +++ b/quest/m2/intra-refresh/bindings.md @@ -1,27 +1,25 @@ -# [M] Bindings expose the Gop enum +# [S] Bindings expose refresh mode ## Goal -Every binding names the group structure the way the core does: the ffi record, -the moq-c C struct, and the Python, Swift, Kotlin, Dart, and Go wrappers take -a keyframe interval or a refresh cycle and nothing else, and `cut()` keeps its -meaning in both modes. This replaces the published `gop` integer in moq-ffi -and changes the moq-c C struct layout, so it targets `dev`. +Every binding names the group structure the way the core does: the moq-ffi +record and the Python, Swift, Kotlin, Dart, and Go wrappers take a keyframe +interval or a refresh cycle and nothing else, and `cut()` keeps its meaning in +both modes. ## Plan -- `rs/moq-ffi/src/video.rs`: `MoqVideoEncoderOutput.gop: Option` becomes - a `MoqVideoGop` enum record mirroring `Gop`, defaulting to keyframes at two - seconds. `MoqVideoProducer::cut()` already has the right name. -- `rs/moq-c/src/video.rs`: `moq_video_encoder_output` gains a - `moq_video_gop` discriminant beside `gop`, zero meaning keyframes, and - `moq.h` is regenerated (build.rs does not do it on source-only changes). - `cpp/obs/src` follows the header. +- [Codecs](/quest/m1/ffi-shape/codec.md) already replaces the `gop` integer + with a `MoqVideoGop` enum mirroring the non-exhaustive `Gop`, so this adds + the refresh variant beside `Keyframe` in `rs/moq-ffi/src/video.rs`, which + is additive and lands on `main`. `MoqVideoProducer::cut()` already has the + right name. +- moq-ffi only: the generated C and C++ bindings inherit the variant. - Hand-written wrappers and docs per the cross-package table: `py/moq-rs`, - `swift/`, `kt/`, `dart/moq`, `go/wrapper/moq`, and `doc/lib/{py,swift,kt,go,dart,c}`. - Go gets no uniffi default, so its zero value must read as keyframe mode. + `swift/`, `kt/`, `dart/moq`, `go/wrapper`, and `doc/lib/{py,swift,kt,go,dart}`. - Run `just test interop --all` for the cross-language check. ## Required -- [Encode config](/quest/m2/intra-refresh/encode-config.md) - the core enum this mirrors +- [Encode config](/quest/m2/intra-refresh/encode-config.md) - the core refresh variant this mirrors +- [Codecs](/quest/m1/ffi-shape/codec.md) - the `MoqVideoGop` enum this extends diff --git a/quest/m2/intra-refresh/consumer-warmup.md b/quest/m2/intra-refresh/consumer-warmup.md index 41326377ff..932c5420d7 100644 --- a/quest/m2/intra-refresh/consumer-warmup.md +++ b/quest/m2/intra-refresh/consumer-warmup.md @@ -22,11 +22,10 @@ replaces both: the rule is timestamp arithmetic on the group start. - Withhold rule, keyed on the same non-continuous signal in both consumers: `js/hang/src/container/consumer.ts` `next()` reports `continuous: false` after a subscribe, a declared discontinuity, or any skip (`#gap`). The Rust - `rs/moq-mux/src/container` `Consumer` has no such signal: `read()` returns a - bare frame and `discontinuity()` covers only empty groups and rewinds, so - this quest adds a per-delivery continuity flag there, set on a sequence gap - and on a latency skip, and `rs/moq-video/src/decode/consumer.rs` propagates - it. For the first + `moq_mux::container::Consumer` gains the equivalent in the open-GOP quest + (today `poll_read` returns a bare frame and only the `discontinuity()` + counter moves); this quest reuses it, and + `rs/moq-video/src/decode/consumer.rs` propagates it. For the first group after that signal, every frame is decoded (the decoder needs them to build reference state) and frames stamped below `group.start + warmup` are not presented; frames stamped at or above that boundary are, so the recovery @@ -41,13 +40,15 @@ replaces both: the rule is timestamp arithmetic on the group start. in `js/hang`. Other codecs never set `warmup`, so no check is needed there. - Join earlier: the subscription's maximum age becomes the latency target plus `warmup`, so the group start lands `warmup` before the target and the first - presented frame is on time. JS sets `Subscription.latencyMax` in `js/net`; - Rust sets `latency_max` on the decode consumer's subscription - (`rs/moq-video/src/decode/consumer.rs`), not `Subscription::group_start`, - which is aggregated across subscribers and rewinds the track for everyone. -- Latency skipping must not shed the warmup span it deliberately joined: - `#checkLatency` in the JS container consumer and `with_latency` in Rust - compare the buffered span against the target, and frames still inside a + presented frame is on time. JS sets the subscription's `maxAge` in `js/net`; + Rust adds it to the decode consumer's `Options::max_age`, which reaches the + subscription through `Subscription::with_max_age` + (`rs/moq-video/src/decode/consumer.rs`), not `Subscription::start`, which + is aggregated across subscribers and rewinds the track for everyone. +- Max-age skipping must not shed the warmup span it deliberately joined: + `#checkMaxAge` in the JS container consumer and the max-age budget in Rust + (`Consumer::poll_read`, set by `set_max_age`) compare the buffered span + against the target, and frames still inside a withheld warmup count as decode-only, not buffered. - Tests in both languages: a synthetic three-group track with `warmup` where a cold join presents nothing before start plus `warmup` and everything after; @@ -59,7 +60,4 @@ replaces both: the rule is timestamp arithmetic on the group start. ## Required - [Catalog warmup](/quest/m1/catalog-warmup.md) - the field this reads - -## Related - -- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - trims frames stamped before the keyframe; this trims frames after the start, on the same signal +- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - adds the Rust non-continuous signal this keys on, and trims frames stamped before the keyframe where this trims frames after the start diff --git a/quest/m2/js-discontinuity.md b/quest/m2/js-discontinuity.md index 36e41aada4..0270c141b2 100644 --- a/quest/m2/js-discontinuity.md +++ b/quest/m2/js-discontinuity.md @@ -1,29 +1,22 @@ -# [S] JS names the break discontinuity() +# [XS] JS discontinuity() writes no estimated end ## Goal -`@moq/hang`'s container producer spells its two operations the way Rust -`moq_mux::container::Producer` does: `cut(end?)` closes the current group, -and `discontinuity()` closes it and writes the empty marker group that tells -subscribers to re-anchor. Today JS's public `cut()` writes the marker, so the -same name means a routine group close in Rust and a timeline break in JS. +`@moq/hang`'s `discontinuity()` without an explicit end closes the group with +no cadence-estimated end, as Rust `moq_mux::container::Producer::discontinuity` +does. Whatever resumes can land sooner than one estimated frame later (a +capture swap), and an end past it reads as a rewind to every consumer. ## Plan -[#4045](https://github.com/moq-dev/moq/pull/4045) deletes -`quest/m1/js-publish-discontinuity.md`, since #3982 already put the marker -in `cut()`; this rename is the only remaining JS work. +The rename landed on `dev` in #4141 and kept the old behavior: in +`js/hang/src/container/legacy.ts`, `discontinuity(end?)` calls `#close(end)`, +which fills a missing `end` with `#end + #interval`. Rust's `discontinuity()` +calls `close(None, None)` and clears the cadence first. -In `js/hang/src/container/legacy.ts`, rename the public `cut(end?)` to -`discontinuity()` (taking the same optional end) and keep the routine close -private until a caller needs it public. Move `js/publish/src/video/encoder.ts` -and any other caller over, and keep data tracks skipping a sequence the way -Rust's `discontinuity()` does if a JS data-track caller appears. Rust is -untouched. +Give the break its own close path that passes no estimate when the caller +gave no end, leaving the routine close's estimate alone. Test that a +discontinuity after a steady cadence writes no end past the last frame. -Match Rust's break, too: without an explicit end, `discontinuity()` closes the -group with no cadence-estimated duration marker. Whatever resumes can land -sooner than one estimated frame later (a capture swap), and a marker past it -reads as a rewind to every consumer. - -Public API: breaking in published `@moq/hang`, so it targets `dev`. Wire: none. +Public API: behavior change in `@moq/hang`'s `discontinuity()`, which exists +only on `dev`, so it targets `dev`. Wire: none. diff --git a/quest/m2/latency-ledger.md b/quest/m2/latency-ledger.md index de59283362..ed059c529b 100644 --- a/quest/m2/latency-ledger.md +++ b/quest/m2/latency-ledger.md @@ -13,8 +13,12 @@ The audio quality harness lands on ad-hoc debug probes, which is the right trade to get it running. This quest promotes them. - Take the stage schema the harness already defines (capture, encode, publish - flush, network, jitter buffer, decode, render) and expose it the way - `moq-stats` exposes relay traffic: an observable readout, not a callback. + flush, network, jitter buffer, decode, render) and expose it as fields of + `hang::Stats`, the media extension the client stats schema adds to + `moq-stats` (`rs/hang/src/stats.rs`, and its `@moq/stats` mirror), rather + than a second readout: an observable value a `.stats` broadcast already + carries, not a callback. Decided so viewers report latency the same way + they report stalls. - Both languages, matching names, per the repo's cross-language rule. Scrutinise each exported item: a stage nobody outside can act on stays internal. - Switch the harness over, deleting the probes it replaces. A ledger with no @@ -29,9 +33,10 @@ trade to get it running. This quest promotes them. ## Required -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - defines the stage schema and lands the probes this promotes +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - defines the stage schema and lands the probes this promotes +- [Schema and library](/quest/m1/qos/stats/schema.md) - adds `hang::Stats`, which this extends ## Related -- [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - defines the stages and is the first consumer +- [Audio quality harness](/quest/m0/audio-quality-harness/README.md) - defines the stages and is the first consumer - [QoS](/quest/m1/qos/README.md) - relay-side health, the same idea from the other end diff --git a/quest/m2/multipath-spike.md b/quest/m2/multipath-spike.md index f26d6804fe..6e7646f68b 100644 --- a/quest/m2/multipath-spike.md +++ b/quest/m2/multipath-spike.md @@ -57,5 +57,8 @@ Naming trap: `Path` in moq-net is the broadcast namespace path, and `Client::with_path` is the MoQ SETUP resource path. A network path needs a different name. -Target `dev`, where the crate is `moq-tokio`; the rename has not reached -`main`. +Target `main`: `apply_transport` lives in `rs/moq-tokio/src/noq.rs` there +too, and the max-paths knob is additive. + +Public API: an additive transport setting in `moq-tokio`. Wire: none; multipath +is negotiated by QUIC transport parameters, below MoQ. diff --git a/quest/m2/obs-wave-layout.md b/quest/m2/obs-wave-layout.md deleted file mode 100644 index b62afa7030..0000000000 --- a/quest/m2/obs-wave-layout.md +++ /dev/null @@ -1,17 +0,0 @@ -# [XS] OBS channel layouts - -## Goal - -The OBS source maps a channel count to the same default layout moq-audio does, -the WAVE convention (3 is 2.1, 4 is quad, 6 is 5.1, 8 is 7.1), so a -multichannel broadcast plays with its speakers where the publisher put them. - -## Plan - -`audio_layout_to_speakers` in `cpp/obs/src/moq-source.cpp` maps from FFmpeg -layouts today. Map each count to the nearest OBS `speaker_layout` and refuse -the ones OBS cannot place rather than guess. Note the mapping in `doc/bin/obs.md`. - -## Related - -- [Audio codecs](/quest/m1/audio-codecs/README.md) - the channel layouts this mirrors diff --git a/quest/m2/pipewire-camera-planes.md b/quest/m2/pipewire-camera-planes.md index d33e8fb114..5cc58a94f8 100644 --- a/quest/m2/pipewire-camera-planes.md +++ b/quest/m2/pipewire-camera-planes.md @@ -2,7 +2,7 @@ ## Goal -A PipeWire camera that delivers I420 or NV12 in separate memory blocks produces frames. Single-block cameras keep working. Other pixel formats stay unsupported. Importing multi-plane NV12 into Vulkan stays with the PipeWire DMA-BUF quest. +A PipeWire camera that delivers I420 or NV12 in separate memory blocks produces frames. Single-block cameras keep working. Other pixel formats stay unsupported. The renderer already imports multi-plane NV12 DMA-BUFs (#3331); this is the shared-memory capture offer. ## Plan @@ -13,4 +13,4 @@ Unit-test the offer, a multi-block NV12 buffer, and a multi-block I420 buffer, w ## Related - [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the pass that shows whether a real Pi or portal camera delivers separate planes -- [PipeWire DMA-BUFs into Vulkan](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - multi-plane NV12 import in the renderer +- [PipeWire DMA-BUFs into Vulkan](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - the hardware validation of the DMA-BUF path diff --git a/quest/m2/plan-routing-origin.md b/quest/m2/plan-routing-origin.md deleted file mode 100644 index 5a0c7f74f3..0000000000 --- a/quest/m2/plan-routing-origin.md +++ /dev/null @@ -1,41 +0,0 @@ -# [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/play-drain-tail.md b/quest/m2/play-drain-tail.md new file mode 100644 index 0000000000..bed7d7e0ed --- /dev/null +++ b/quest/m2/play-drain-tail.md @@ -0,0 +1,24 @@ +# [XS] moq play plays a finished track's last samples + +## Goal + +When `moq play` retires an audio rendition or reaches the end of a finite +track, every sample it wrote reaches the speaker. Today `drain` in +`rs/moq-cli/src/play/media.rs` returns once 10 ms or less is buffered and +drops the sink, which removes it from the mix, so each retired rendition and +finite track loses up to its last 10 ms +([#4154](https://github.com/moq-dev/moq/pull/4154)). The rendition-switch test +tolerates the gap. + +## Plan + +The 10 ms stop exists because polling the last partial period costs a wakeup +per iteration and never settles. Fix it at the sink instead of the poll: let +a dropped or finished `moq_audio::playback::Sink` play out what it holds +before leaving the mix, or give it an end-of-stream that the mixer honors, so +`drain` no longer needs a threshold. If that belongs in moq-audio's playback +API, keep the change additive. + +Tighten `an_audio_rendition_switch_leaves_no_gap` (the `play::fake::Recorder` +already models the cut on drop) so the lost tail fails it, and add a finite +track that asserts its final samples are heard. diff --git a/quest/m2/quic-bbr-google.md b/quest/m2/quic-bbr-google.md index 869b3c8fa7..63c90992fe 100644 --- a/quest/m2/quic-bbr-google.md +++ b/quest/m2/quic-bbr-google.md @@ -34,11 +34,7 @@ not an end-to-end network measurement. Persist a reproducible harness in CI separate implementation quest for any adopted change rather than silently expanding this study into a controller rewrite. -## Required - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - measure a baseline without the seven known defects - ## Related -- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - reuse media profiles and measurements +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - reuse media profiles and measurements - [Upstream the fork](/quest/m1/quic/upstream.md) - share useful findings with upstream diff --git a/quest/m2/quic-bbr-app-limited.md b/quest/m2/quic-bbr-natural-drain.md similarity index 95% rename from quest/m2/quic-bbr-app-limited.md rename to quest/m2/quic-bbr-natural-drain.md index d6b7a29846..561b4bea8b 100644 --- a/quest/m2/quic-bbr-app-limited.md +++ b/quest/m2/quic-bbr-natural-drain.md @@ -40,10 +40,6 @@ harness in CI, with broader network cases at least nightly, and a verdict with pinned sources/configurations. Any adopted production policy gets a separate implementation quest; this study does not silently change defaults. -## Required - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - measure the corrected controller through MoQ's dependency chain - ## Related - [Discover media headroom](/quest/m2/quic-probe.md) - preserving an estimate and discovering spare capacity are separate problems diff --git a/quest/m2/quic-ecn.md b/quest/m2/quic-ecn.md index e4b0e9d24e..3d8e415b83 100644 --- a/quest/m2/quic-ecn.md +++ b/quest/m2/quic-ecn.md @@ -9,9 +9,9 @@ marks fall back to no ECN, and a viewer's session is unaffected. ## Plan -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. +ECT(0) marking and ACK ECN counts are carried end to end, and BBR's classic +CE response ([moq-dev/noq#12](https://github.com/moq-dev/noq/pull/12), in +moq-noq 1.3.2) is 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. @@ -35,6 +35,5 @@ need the fork. ## 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/quest/m2/quic-kernel-pacing.md b/quest/m2/quic-kernel-pacing.md index af68254040..f8d3b32915 100644 --- a/quest/m2/quic-kernel-pacing.md +++ b/quest/m2/quic-kernel-pacing.md @@ -3,9 +3,12 @@ ## Goal A measured verdict on handing packet pacing to the kernel. Today noq's pacer -is a userspace token bucket, and the io_uring driver ignores its hint -entirely: a GSO train leaves as one burst, bounded only by the congestion -window. Either the kernel paces each train (`SO_TXTIME` with the `etf` or +is a userspace token bucket on both runtimes: `poll_transmit` holds a train +until the pacing timer fires, which the io_uring driver arms through +`poll_timeout` like the tokio driver does, but a released GSO train still +leaves the NIC as one burst. The `flush_one` comment in +`rs/moq-uring/src/quic/noq/connection.rs` saying the driver ignores the hint +is stale from quiche. Either the kernel paces each train (`SO_TXTIME` with the `etf` or `fq` qdisc, per-packet transmit times in the cmsg the driver already builds) and burstiness at the bottleneck drops without costing CPU, or the burst is shown not to matter on the fleet's paths. diff --git a/quest/m2/quic-probe.md b/quest/m2/quic-probe.md index 5926256767..f1e8ef3e06 100644 --- a/quest/m2/quic-probe.md +++ b/quest/m2/quic-probe.md @@ -44,12 +44,8 @@ is not proof of full path capacity. Persist regressions in CI and broader network scenarios at least nightly. A measured no-go is a valid outcome; retain the baseline and record why before exposing an ineffective option. -## Required - -- [Release BBR fixes](/quest/m1/quic/bbr-release.md) - exclude known controller defects from the experiment - ## Related -- [Natural media drains](/quest/m2/quic-bbr-app-limited.md) - separate ProbeRTT policy experiment +- [Natural media drains](/quest/m2/quic-bbr-natural-drain.md) - separate ProbeRTT policy experiment - [FEC experiment](/quest/m2/quic-fec.md) - repetition competes for the redundancy budget - [GCC egress experiment](/quest/m2/quic-gcc.md) - delay control changes what headroom means diff --git a/quest/m2/redundant-ingest.md b/quest/m2/redundant-ingest.md index 6713d87dd3..96ce3c0644 100644 --- a/quest/m2/redundant-ingest.md +++ b/quest/m2/redundant-ingest.md @@ -4,20 +4,26 @@ Decide whether and how two live publishers of identical content share one broadcast, so viewers survive losing one faster than the QUIC keep-alive. -Epochs let the publishers claim one identity (the same `@`), but -today's #3312 rule splices only across routes with the same first hop, so -two ingest hosts are two identities. The study may end in a no-go. +The study may end in a no-go. ## Plan -Open questions: whether an explicitly shared epoch may splice across first -hops at a group boundary, and what makes that safe (group sequences aligned -across encoders, a matching catalog). Also who declares the incumbent dead -early: a failover service that retracts it, or active-active delivery to the -relay. Weigh them against the moq-transport rule that multiple publishers of -a namespace must each be asked (#3697) and the cluster draft. Output: a +Start from what is documented today (`doc/bin/cli.md` "Redundant +publishers"): two encoders sharing a Hop ID (`--hop 42`) are one first hop, +so relays hold both routes and fail over at a group boundary under the #3312 +same-first-hop rule, provided the tracks are identical with aligned groups. + +Open questions: how that maps onto epochs (the pair claiming one +`@`), what enforces the alignment the docs only ask for (group +sequences, a matching catalog), and who declares the incumbent dead early: a +failover service that retracts it, or active-active delivery to the relay. +[Cluster routing](/quest/m1/cluster-routing.md) drops hop lists inside a +cluster and must decide what replaces this failover; follow its answer. +Weigh them against the moq-transport rule that multiple publishers of a +namespace must each be asked (#3697) and the cluster draft. Output: a decision, with a quest for the chosen mechanism. ## Related +- [Cluster routing](/quest/m1/cluster-routing.md) - decides what replaces first-hop failover inside a cluster - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - explicit epochs are what a redundant pair would share diff --git a/quest/m2/relay-io-uring-package.md b/quest/m2/relay-io-uring-package.md new file mode 100644 index 0000000000..23dc0e46d9 --- /dev/null +++ b/quest/m2/relay-io-uring-package.md @@ -0,0 +1,41 @@ +# [S] Linux relay packages ship io_uring + +## Goal + +The official Linux moq-relay builds (the `.deb`, `.rpm`, and tarballs from +`.github/workflows/moq-relay.yml`, and the Nix package in `nix/overlay.nix`) +are compiled with the `io-uring` feature, so `--runtime-io-uring` works on a +packaged relay. Today none of them enable it, yet the packaged systemd unit +sets `LimitMEMLOCK=infinity` for io_uring +([#4197](https://github.com/moq-dev/moq/pull/4197)): the unit prepares for a +runtime the binary cannot run. The runtime stays opt-in; this changes what +ships, not the default. + +## Plan + +- Decided: ship it, but only once the io_uring runtime is on par with the + tokio one for what a packaged relay promises: every configured protocol + served, the `[quic]` tuning honored, and closes delivered. Offering a flag + that silently serves less than the default runtime would be worse than not + offering it. Performance work is not a prerequisite. +- Enable the feature only on Linux targets. Confirm the zigbuild glibc 2.34 + build still links and that nothing new is needed at runtime. +- A kernel without the io_uring features the workers need must make + `--runtime-io-uring` fail loud at startup with the reason, not fall back. +- Package smoke check in the release workflow: start the packaged binary + with `--runtime-io-uring` on the runner, accept one session, and exit, + failing the release if it cannot. Check it under the unit's limits too, + since memlock is why #4197 touched the unit. +- Docs: `doc/bin/relay/` says packaged Linux builds include the runtime, how + to turn it on, and the memlock note. + +Public API: none. Wire: none. + +## Required + +- [moq-transport on io_uring](/quest/m1/uring-ietf.md) - a packaged relay + must not drop protocols when the ring is on +- [Flow-control windows](/quest/m1/uring-flow-control-windows.md) - the + `[quic]` section must not be refused at startup on the ring +- [Close before teardown](/quest/m1/quic/uring-close.md) - sessions on the + ring must end with their application close diff --git a/quest/m2/routing-cost-domains.md b/quest/m2/routing-cost-domains.md index 9e045ed5e4..58e0ab5f1f 100644 --- a/quest/m2/routing-cost-domains.md +++ b/quest/m2/routing-cost-domains.md @@ -3,10 +3,13 @@ ## Goal Settle how independently operated MoQ networks exchange reachability without -adding incomparable costs. Produce a reviewed design and scoped implementation -quests, not a protocol implementation. Cloudflare, moq.pro, and self-hosted -relays can retain their own business policy; no RTT/loss-driven repricing or -automatic performance failover is authorized by this work. +adding incomparable costs, at the cluster boundaries where +[Cluster routing](/quest/m1/cluster-routing.md) keeps path vector with cluster +ids as hops. Cost inside one cluster is cluster-routing's. Produce a +reviewed design and scoped implementation quests, not a protocol +implementation. Cloudflare, moq.pro, and self-hosted relays can retain their +own business policy; no RTT/loss-driven repricing or automatic performance +failover is authorized by this work. ## Plan @@ -46,16 +49,14 @@ Explain how warm-route marginal savings interact with border policy without pretending those savings erase upstream delay. State tradeoffs, migration and mixed-version behavior, and the limits of any convergence claim. -Reconcile the existing directional charged/declared cost plan rather than -creating a second peer-policy mechanism. Completion is a documented decision, +Reconcile with cluster-routing's configured link costs rather than creating +a second peer-policy mechanism. Completion is a documented decision, worked counterexamples or model checks, and independently completable follow-up quests. Open wire/API choices belong to this design exercise. ## Related -- [Peer reconfigure](/quest/m1/pop-skipping/peer-reconfigure.md) - existing - charged versus declared directional policy -- [PoP skipping](/quest/m1/pop-skipping/README.md) - coordinated fleet economics - and warm-route behavior +- [Cluster routing](/quest/m1/cluster-routing.md) - in-cluster topology and + cost; this designs only what crosses its boundaries - [#3769](https://github.com/moq-dev/moq/pull/3769) - measurement-based pricing prompted the separation of measurement, operator policy, and protocol diff --git a/quest/m2/sei/README.md b/quest/m2/sei/README.md index 4c0a7ea2e7..da44b7e4f8 100644 --- a/quest/m2/sei/README.md +++ b/quest/m2/sei/README.md @@ -19,7 +19,7 @@ Total storage and bytes delivered to a video-only subscriber are different measurements. Any future split must state which payloads move and how missing metadata is handled; do not promise byte-faithful export after a deadline miss. -## Quests +## Required - [SEI evidence](/quest/m2/sei/evidence.md) - measure savings and identify a consumer before deciding whether to split - [SEI section](/quest/m2/sei/sei.md) - define a format only after a positive verdict and settled association policy diff --git a/quest/m2/stats-delta.md b/quest/m2/stats-delta.md index b60e1b9afa..475e7c6532 100644 --- a/quest/m2/stats-delta.md +++ b/quest/m2/stats-delta.md @@ -112,4 +112,4 @@ impact: new on-demand tracks; existing tracks unchanged. - [Stats format page](/doc/concept/stats.md) - where the new flavor is documented - [Client stats](/quest/m1/qos/stats/README.md) - the extension and gauges the format must carry or refuse -- [Compressed tracks](/quest/m2/flate/README.md) - the group-window discipline this flavor repeats +- [Compressed tracks](/quest/m2/flate/README.md) - group-scoped DEFLATE tracks, whose group-window discipline this flavor repeats diff --git a/quest/m2/teleop/README.md b/quest/m2/teleop/README.md index 0593ccfdc3..fa7e5f5a4f 100644 --- a/quest/m2/teleop/README.md +++ b/quest/m2/teleop/README.md @@ -24,11 +24,10 @@ server walks the announce stream to fan the viewers in demo and private to it. Every integrator rebuilds the announce fan-in, the operator arbitration, and the latency instrumentation from scratch. -Two things are genuinely missing rather than merely undocumented: the hang -catalog is video plus audio only (location tracks arrived in moq#401 and were -dropped when the catalog became generic), and `moq-video`'s V4L2-M2M encoder -backend is compiled into no released `moq-cli`, so the boards that fly have no -native hardware-encode path anyone can install. +The hang catalog is no longer a gap: it advertises data tracks in its `json` +and `binary` sections beside video and audio. What is genuinely missing is +`moq-video`'s V4L2-M2M encoder backend in a released `moq-cli`, so the boards +that fly have no native hardware-encode path anyone can install. ### Two delivery classes, one session @@ -45,8 +44,9 @@ a lossy link produces latency spikes rather than delivery, and an emulation study on a long-RTT profile measured command staleness more than twice as bad for QUIC reliable streams as for DDS best-effort: correct and useless. -The split is a framing decision, not a subscription flag, and `moq-json` -already implements both halves as its snapshot and stream modes. What that +The split is a framing decision, not a subscription flag, and `moq-json` and +`moq-binary` already implement both halves as their snapshot and stream +modes. What that means for the primitive is in [robot](/quest/m2/teleop/robot.md), and what it means for a protocol multiplexing many message rates onto one link is in [mavlink](/quest/m2/teleop/mavlink.md). @@ -78,7 +78,7 @@ VPN, the competing UDP flows, and the signalling server at once, and survives cell handover through connection migration. That is the pitch, and it is worth stating plainly because it is what a builder is comparing against. -## Quests +## Required - [Robot teleoperation primitive](/quest/m2/teleop/robot.md) - a `moq-robot` crate carrying the track shapes and discovery every teleoperated machine @@ -98,8 +98,8 @@ stating plainly because it is what a builder is comparing against. - [V4L2-M2M encoding](/quest/m2/teleop/v4l2-encode.md) - a released `moq-cli` reaches `moq-video`'s hardware encoder on the boards that fly, and the boards worth buying are written down -- [Teleoperation use-case docs](/quest/m2/teleop/docs.md) - `doc/concept/use-case/other.md` - becomes the teleoperation page, with a runnable non-media example beside it +- [Teleoperation use-case docs](/quest/m2/teleop/docs.md) - `doc/concept/use-case/` + gains a teleoperation page, with a runnable non-media example beside it - [ROS 2 bridge](/quest/m2/teleop/ros2.md) - a ROS 2 bridge sibling to the MAVLink one, carrying topics over the same two delivery classes - [Cross-track correlation](/quest/m2/teleop/correlation.md) - a command, the diff --git a/quest/m2/teleop/browser-package.md b/quest/m2/teleop/browser-package.md index c447df9a37..56e4c29449 100644 --- a/quest/m2/teleop/browser-package.md +++ b/quest/m2/teleop/browser-package.md @@ -7,14 +7,14 @@ and delivery classes rather than reimplementing them. ## Plan -Follow the existing split: `net`, `hang`, `json` and `token` each have a Rust +Follow the existing split: `net`, `hang`, `json` and `auth` each have a Rust crate and a TypeScript package, with zod schemas mirroring the Rust types. There is no `@moq/mux`, so the catalog extension goes through the same seam `js/hang/src/catalog/root.ts` uses to extend the root schema. A browser observer cannot degrade the operator's classes (`clamp_combined` -bounds the window at the publisher, and `Subscription::default()` is already -`Duration::ZERO`), so this package exists for reuse, not for safety: the +bounds the window at the publisher, and `Subscription::max_age` already +defaults to `Duration::ZERO`), so this package exists for reuse, not for safety: the catalog schema, the snapshot and append-log shapes, and the instrumentation are the same on both sides and should be written once. diff --git a/quest/m2/teleop/docs.md b/quest/m2/teleop/docs.md index 78c95c178b..96266a4044 100644 --- a/quest/m2/teleop/docs.md +++ b/quest/m2/teleop/docs.md @@ -2,12 +2,12 @@ ## Goal -`doc/concept/use-case/other.md` stops being a zero-byte stub and becomes the -teleoperation page, with a runnable non-media example beside it. +`doc/concept/use-case/` gains a teleoperation page, listed in its +`index.md`, with a runnable non-media example beside it. ## Plan -The docs have no non-media tutorial. `rs/moq-native/examples/{chat,clock}.rs` +The docs have no non-media tutorial. `rs/moq-tokio/examples/{chat,clock}.rs` and `rs/moq-json/examples/telemetry.rs` all publish something that is not audio or video, but none is presented as the way to carry application data, and telemetry.rs measures wire savings rather than teaching the shape. diff --git a/quest/m2/teleop/mavlink.md b/quest/m2/teleop/mavlink.md index f13c360ed7..5d545ee4d3 100644 --- a/quest/m2/teleop/mavlink.md +++ b/quest/m2/teleop/mavlink.md @@ -41,25 +41,24 @@ because the newest group wins regardless of which message it holds. Putting them all in one long-lived group has the opposite failure: nothing can be skipped and head-of-line blocking is back. -So the lossy class is a latest-value snapshot, the shape `moq_json::snapshot` -implements, with the frame as an opaque value. Two details decide whether it -actually delivers latest-value: +So the lossy class is a latest-value snapshot of opaque bytes: +`moq_binary::snapshot` (moving to `moq_flate::snapshot` in +[moq-binary folds into moq-flate](/quest/m1/flate-binary.md)), with the raw +frame as the value. Every update is a self-contained group, so a newer value +never waits behind an older one. One detail decides whether it actually +delivers latest-value: - **Key on `(sysid, compid, msgid)`, not msgid alone.** A vehicle is several components, and an autopilot, a gimbal and a camera all emit `HEARTBEAT` under msgid 0; keying on msgid alone lets them overwrite each other. Decide explicitly what to do about instances distinguished only inside a payload, which the msgid-only rule cannot see. -- **Set the encoder's delta ratio to 0.** By default `moq_json::snapshot` - batches up to `MAX_DELTA_FRAMES` merge patches into one ordered group - (`rs/moq-json/src/snapshot/encoder.rs`), so under congestion an earlier 50 Hz - attitude delta head-of-line blocks a later heartbeat inside that group. A - ratio of 0 makes every change a self-contained snapshot, which is the - latest-value contract; anything else has to justify the added delay. -`moq-flate` is the existing answer if the encoding overhead matters. +Not `moq_json::snapshot`: a MAVLink frame is not JSON, and its default batches +up to `MAX_DELTA_FRAMES` merge patches into one ordered group, so an earlier +50 Hz attitude delta would head-of-line block a later heartbeat. -The reliable class is the append-log shape (`moq_json::stream`): commands, +The reliable class is the append-log shape (the binary `stream` mode): commands, ACKs, mission, parameter and file transfer are stop-and-wait exchanges carried in one ordered group. Note the scope in [robot](/quest/m2/teleop/robot.md): that is gap-free for a live reader, not diff --git a/quest/m2/teleop/robot.md b/quest/m2/teleop/robot.md index 42614ceb11..5dd2735138 100644 --- a/quest/m2/teleop/robot.md +++ b/quest/m2/teleop/robot.md @@ -34,41 +34,46 @@ QUIC stream (`open_uni`, `rs/moq-net/src/lite/publisher.rs`), so frames inside it are ordered and delivered exactly once. That guarantee is scoped, and the crate must say so rather than promise -losslessness. A group caches at most `MAX_GROUP_CACHE` bytes and evicts frames -off the front (`rs/moq-net/src/model/group.rs`); a reader that falls behind the -retained window, or joins late, gets `Error::Lagged` and cannot recover the -start of the log. So the contract is ordered, gap-free delivery for a live -reader that keeps up, and recovery after a reconnect belongs to the application +losslessness. A group holds at most `MAX_CACHE_BYTES` and `MAX_GROUP_FRAMES` +(`rs/moq-net/src/model/group.rs`), and a write past either aborts it with +`Error::GroupTooLarge`; `moq_json::stream` never rolls its one group, so that +bound ends the track, and a link drop or reconnect loses what was in flight. +So the contract is ordered, gap-free delivery for a live reader within one +bounded log, and recovery after a reconnect belongs to the application protocol. That is an acceptable division for MAVLink, whose mission, parameter and file-transfer services already carry their own stop-and-wait retransmission, but it must be stated, not assumed. The framing is where the guarantee lives, not the subscription flags: -- `Subscription::ordered` is a scheduling tie-break whose own doc says groups - may arrive out of order or not at all. It aggregates across subscribers by - `&&`, and the IETF transport does not carry it. A class built on it would not - be reliable, which is why the reliable class is a single group instead. +- `track::Subscriber::ordered()` returns an `Ordered` handle that reads the + groups it receives in sequence order. It is a local cursor, not a delivery + guarantee: groups that aged out or were skipped never arrive. A class built + on it would not be reliable, which is why the reliable class is a single + group instead. - What makes the lossy class lossy on the wire is the publisher's - `Info::latency_max`: `commit_group` calls `evict_expired`, which calls - `slot.group.abort(Error::Old)` (`rs/moq-net/src/model/track.rs`), and an abort - resets the QUIC stream, so stale bytes stop being retransmitted. + `Info::max_age`: `evict_expired` aborts an aged-out group with `Error::Old` + (`rs/moq-net/src/model/track.rs`), and an abort resets the QUIC stream, so + stale bytes stop being retransmitted. - A subscriber cannot weaken either class. `clamp_combined` (`rs/moq-net/src/model/track.rs`) clamps the aggregate window down to - `Info::latency_max`, and `Subscription::default()` is already + `Info::max_age`, and `Subscription::max_age` already defaults to `Duration::ZERO`, so a raw observer subscribing through `moq-net` neither widens the window nor has to be prevented from trying. ### Contents -- The catalog section, through `moq-mux`'s `CatalogExt` and - `RenditionConfig`: a namespaced root section whose entries embed a - `JsonConfig` or `BinaryConfig` beside the robot's own fields, published - through the `moq-mux` data producers. +- The catalog entries. The hang catalog already advertises data tracks in its + `json` and `binary` sections (`rs/hang/src/catalog/{json,binary}.rs`), + written by the `moq-mux` data producers, and each entry carries `extra` + fields. Decide whether the robot's fields ride there or in a namespaced + root section through `moq-mux`'s `CatalogExt` and `RenditionConfig`. No hang schema change. - Announce-prefix fan-in, generalised from `rs/moq-boy/src/input.rs`. -- The two delivery classes, as `moq-json`'s snapshot and stream modes with - the group structure and `Info::latency_max` each one needs. +- The two delivery classes, as the snapshot and stream modes with the group + structure and `Info::max_age` each one needs: `moq-json`'s for JSON, and the + opaque-bytes ones for binary frames (`moq-binary`, folding into `moq-flate` + per [moq-binary folds into moq-flate](/quest/m1/flate-binary.md)). - Per-stage timestamp instrumentation, generalised from moq-boy's `status` track. Check it against the publisher-reported stats broadcast ([client stats](/quest/m1/qos/stats/schema.md), moq#2734) before adding a diff --git a/quest/m2/watch-data-sync.md b/quest/m2/watch-data-sync.md index 19b0b1f440..a8afced6e0 100644 --- a/quest/m2/watch-data-sync.md +++ b/quest/m2/watch-data-sync.md @@ -19,10 +19,6 @@ releases it. - Take this up when an application needs synchronized data playback; until then, a raw consumer reads payloads as they arrive. -## Required - -- [Data jitter](/quest/m1/data-jitter.md) - data tracks advertise the `delay` and `jitter` this reads - ## Related - [Cross-track correlation](/quest/m2/teleop/correlation.md) - joins recordings on the broadcast clock rather than live playout diff --git a/quest/m2/whep-abr.md b/quest/m2/whep-abr.md index ed9896b80a..af2367d95a 100644 --- a/quest/m2/whep-abr.md +++ b/quest/m2/whep-abr.md @@ -12,10 +12,5 @@ Open questions: whether to drive switching from str0m's bandwidth estimate (TWCC) or from loss and REMB, how to switch without a keyframe gap (subscribe the new rendition and splice at its next group, as the JS decoder does), and whether offering the renditions as WebRTC simulcast layers (RIDs) buys -anything for a receive-only browser. Keep the -[best-rendition pick](/quest/m1/egress-rendition-pick.md) as the starting -rendition. - -## Required - -- [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - the shared ranking this starts from +anything for a receive-only browser. Keep the best rendition +(`hang::catalog::Video::ranked`) as the starting rendition. diff --git a/quest/m3/README.md b/quest/m3/README.md index d4403e5bef..ac3b0631f5 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -12,7 +12,7 @@ A quest lands here when its gate is the outside world, not its priority. Each states the condition in prose or as a plain-text `Required` bullet. When the condition clears, move the quest to the milestone its work belongs in. -## Quests +## Required - [DPDK](/quest/m3/dpdk.md) - a kernel-bypass UDP path for the relay, once a provider offers SR-IOV or bare metal - [Video hardware validation](/quest/m3/video-hardware.md) - run the encode, capture, and zero-copy paths that were written but never run on real machines @@ -20,3 +20,5 @@ condition clears, move the quest to the milestone its work belongs in. - [#2893](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - video: validate PipeWire DMA-BUF capture on KDE hardware - [Embedded video](/quest/m3/video-embedded.md) - EGL import in the renderer, so moq-video presents on a Pi - [Vision worker](/quest/m3/processor-vision.md) - a documented customer-run vision worker proves the processor contract +- [Suffix announce](/quest/m3/suffix-announce.md) - moq-lite-only suffix announce and interest, benchmarked over the announce table, once a deployment needs a claim a service prefix cannot express +- [Upstream forks](/quest/m3/upstream-forks.md) - offer the uniffi generator fixes our cpp, dart, and Python forks carry upstream, lowest priority diff --git a/quest/m3/dpdk.md b/quest/m3/dpdk.md index c9ec3a466f..efaf48e21c 100644 --- a/quest/m3/dpdk.md +++ b/quest/m3/dpdk.md @@ -22,10 +22,7 @@ a bare-metal tier is worth operating. ## Required +- [AF_XDP UDP path](/quest/m2/af-xdp.md) - the no-hardware verdict this waits + on; a kernel path within reach of line rate abandons this quest - A moq.pro relay provider offers SR-IOV or bare-metal hosts the fleet can run on - -## Related - -- [AF_XDP UDP path](/quest/m2/af-xdp.md) - the no-hardware verdict this waits - on diff --git a/quest/m3/suffix-announce.md b/quest/m3/suffix-announce.md new file mode 100644 index 0000000000..5e061f3b39 --- /dev/null +++ b/quest/m3/suffix-announce.md @@ -0,0 +1,42 @@ +# [L] Suffix announce and interest in moq-lite + +## Goal + +A moq-lite subscriber can request announcements by suffix (`**/transcode.pro`) +and a publisher can advertise one, so a fleet-wide service claims every +matching path once instead of per prefix. moq-lite only: IETF sessions stay +prefix-only. Routing only: token claim patterns keep their suffix support +unchanged. + +## Plan + +Decided in the 2026-09-28 quest audit: suffix routing is dropped from every +other quest, which are prefix-only, and lives here as a moq-lite-only +extension. IETF MoQT refuses suffix matching, so a session negotiated as IETF +never carries it and there is no draft to converge with. + +Today `AnnounceRequest` (`rs/moq-net/src/lite/announce.rs`) carries only a +`prefix`, and the origin's route table is a trie keyed by path segment +(`rs/moq-net/benches/origin.rs`). A suffix cannot walk that trie, so a naive +match costs the whole announce table on every announcement and every new +cursor. Requests hit the same wall: `request_broadcast` resolves through +`best_route`, which walks the prefix trie for the longest covering claim, so +a suffix advertisement must also be inserted where SUBSCRIBE and FETCH +resolution find it, with the same specificity rules as prefix claims. +Benchmark first: extend `rs/moq-net/benches/origin.rs` with suffix cursors +and suffix route lookups, each swept over publishers and subscribers. The +slopes decide between a reversed-segment index and abandoning the quest. + +The wire field is version-gated like `hidden`. Update `js/net` and +`drafts/draft-lcurley-moq-lite.md` in the same PR. Token patterns +(`moq-pattern`, `moq_auth::Claims`) already match suffixes and do not change. + +## Required + +- A deployment needs a suffix claim that a service prefix (`./...`) + cannot express + +## Related + +- [Wildcard](/quest/m0/wildcard/README.md) - prefix-only advertisements and the service-prefix layout this would extend +- [Path patterns](/quest/m1/path-patterns.md) - owns the pattern dialect a suffix interest would reuse diff --git a/quest/m3/upstream-forks.md b/quest/m3/upstream-forks.md new file mode 100644 index 0000000000..45856ec20e --- /dev/null +++ b/quest/m3/upstream-forks.md @@ -0,0 +1,59 @@ +# [S] Offer the uniffi generator fixes upstream + +## Goal + +Every general fix our uniffi generator forks carry has been offered to its +upstream, or recorded as declined or MoQ-specific, so each fork shrinks +toward a pin on an upstream tag. Very low priority: the forks work, and the +first step is someone else's review. Every external post, issue, or PR needs +the maintainer's approval at the time it is made. + +One outcome per candidate: + +- **uniffi-bindgen-cpp** (`kixelated/uniffi-bindgen-cpp`, forked from + LiveKit's `uniffi-0.31-async`, PR #1; + [#4100](https://github.com/moq-dev/moq/pull/4100)): the two leak fixes (a + ready Rust future freed without `rust_future_complete`, and a dropped + foreign future that never completed its oneshot), the missing + `#include ` MSVC needs, the uniffi 0.32 port, and + `error_style = "expected"`. The bug fixes are the easy offer; the 0.32 port + and the expected style depend on LiveKit taking async at all. +- **uniffi-dart** (`kixelated/uniffi-dart`; + [#4072](https://github.com/moq-dev/moq/pull/4072)): the + `nix/uniffi-dart-record-error.patch` and the RustBuffer release fixes. Fix + the latent `lowerForeignBytes` leak first (borrowed `&[u8]` arguments + allocate a `ForeignBytes` nothing frees; the free belongs after the call, + not inside the lowering), and audit callback interfaces for the same + RustBuffer leak, so the upstream offer is complete. `moq_ffi` uses neither + today. +- **uniffi-rs Python typing** of data-carrying enum variants + ([#4049](https://github.com/moq-dev/moq/pull/4049)): the generated type + makes `moq.VideoEncoderKind.AUTO()` fail pyright, so + `doc/lib/py/index.md` carries a `pyright: ignore`. Fix it at the source + and drop the ignore once a release carries it. + +## Plan + +- Decided in #4100: fork tags keep upstream's `v+v` + scheme with a `-kixelated.N` pre-release, e.g. + `v0.11.0-kixelated.1+v0.32.2`, matching the Dart fork. It never collides + with an upstream tag. Under SemVer a pre-release sorts before its base, so + this only works because every pin names the exact tag; never let tooling + pick "the newest" fork tag (Codex on #4307). The Go fork (`v0.9.0+v0.32.0`) moves to it + at its next bump. +- Offer each fix with the regression test it landed with. When upstream + merges one, move the pin in `flake.nix` (and the places its comment lists) + and delete the carried patch. +- Record each outcome (merged, declined with the reason, or not offered + because it is MoQ-specific) in the PR that finishes this quest. + +Public API: none. Wire: none. + +## Required + +- The maintainer approves offering the fixes upstream, per post, issue, or PR + +## Related + +- [Upstream the fork](/quest/m1/quic/upstream.md) - the same practice for the noq fork +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the line that forked the C++ generator diff --git a/quest/m3/video-embedded.md b/quest/m3/video-embedded.md index 15fd63162d..a844e2f7df 100644 --- a/quest/m3/video-embedded.md +++ b/quest/m3/video-embedded.md @@ -24,3 +24,8 @@ libcamera source composes with what already exists, because `encode::Producer::publish` is the bring-your-own-Annex-B path and `moq_mux::codec::h264` already handles framing, so shelling out to `rpicam-vid` is an application concern rather than a moq-video source. + +## Required + +- Someone with a Raspberry Pi or similar V4L2 M2M device without a usable + Vulkan driver validates the EGL import on it diff --git a/quest/m3/video-hardware.md b/quest/m3/video-hardware.md index 355e929ae5..c8bf739fab 100644 --- a/quest/m3/video-hardware.md +++ b/quest/m3/video-hardware.md @@ -27,6 +27,12 @@ Precedent for what this catches: NVENC validation on an RTX 3070 Ti found that NVENC rejects stream-ordered pool memory, so buffers registered with it must come from plain `cuMemAlloc`. That is not a bug any amount of review finds. +## Required + +- Someone with the hardware runs it: an Intel GPU exposing the VAAPI low-power + entrypoint, a second render node, a V4L2 capture device with DMA-BUF export, + a Windows machine with MJPEG and YUY2 cameras, and a live camera per platform + ## Related - [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the camera portal and a Pi CSI node, which are a different machine from this list diff --git a/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md b/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md deleted file mode 100644 index 37bbd3470f..0000000000 --- a/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md +++ /dev/null @@ -1,54 +0,0 @@ -# [L] Reach the browser through moq-ffi instead of a second hand-written wasm binding - -## Goal - -The browser holds the moq-ffi surface through a generated TypeScript binding -over `moq-ffi` compiled to wasm32, instead of `moq-wasm` growing a second -hand-written copy of the model (#2814 was closed for that reason). The two -`moq-wasm` gaps close by construction, because moq-ffi already binds them: -datagrams (#2822) and `track::Dynamic` so a browser publisher can serve a -cache-miss fetch (#2835). `@moq/net` stays the browser API; this is the -Rust-in-browser path `moq-wasm` experiments with today. - -## Plan - -The #2907 spike measured the shape: with `moq-native`, the codecs, and the -tokio runtime gated behind `cfg(not(target_arch = "wasm32"))` and uniffi's -`wasm-unstable-single-threaded` feature, 82 of 160 exported methods survive -on wasm32, the whole raw `moq-net` model among them; `uniffi-bindgen-js` -emits a `tsc --strict`-clean package where `u64` is `bigint` and a pending -call owns its own `Arc`, the two points that sank #2814; and the raw wasm is -about 8 % larger than `moq-wasm` for roughly ten times the surface. - -What waits on the outside world is the generator: `uniffi-bindgen-js` is -0.2.1 with a few hundred downloads and `uniffi-bindgen-react-native` calls -itself not for production. Nothing in the spike ran in a browser. - -When the gate clears: - -- Gate `moq-ffi` for wasm32 as the spike did, with separate `#[cfg]`'d - `#[uniffi::export] impl` blocks (a `#[cfg]` on a method inside one block is - ignored by the macro). Decide `Task` semantics per target (native spawns, - wasm awaits in place so a dropped promise cancels) and keep one - WebTransport adapter rather than a second copy of `moq-wasm/src/transport.rs`. -- Decouple `MoqBroadcastProducer` from the hang catalog so a raw-model - publish exists; it is the one non-`#[cfg]` change. -- Pick the generator with a browser harness in place (`test/wasm` runs the - package in headless Chromium) and prove datagrams and a served cache-miss - fetch end to end, including the documented drop on versions that cannot - carry datagrams. - -## Required - -- [moq-mux compiles for wasm32](/quest/m1/mux-wasm-target.md) - the two latent blockers, fixed now -- A `uniffi-bindgen-js` release its authors call stable, or `uniffi_bindgen` shipping a JS backend - -## Closes - -- [#2907](https://github.com/moq-dev/moq/issues/2907) - close this issue when the quest finishes -- [#2822](https://github.com/moq-dev/moq/issues/2822) - close this issue when the quest finishes -- [#2835](https://github.com/moq-dev/moq/issues/2835) - close this issue when the quest finishes - -## Related - -- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the same generate-not-hand-roll rule, on a generator that exists diff --git a/quest/m4/README.md b/quest/m4/README.md index de01a3059f..2dbf68dde3 100644 --- a/quest/m4/README.md +++ b/quest/m4/README.md @@ -11,10 +11,9 @@ Each quest states its gate as a plain-text `Required` bullet. Re-check the gates periodically; when one clears, remove the bullet and promote the quest to the milestone its priority belongs in. -## Quests +## Required - [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 encode and decode, and pre-generated bindings that remove the libclang build dependency, gated on a moq-dev/vaapi release - [Pool VAAPI resize surfaces](/quest/m4/vaapi-resize-pool.md) - a resize reuses one output surface per size once moq-vaapi ships that pool -- [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser reaches moq-ffi through a generated TypeScript binding once a JS generator is stable - [Safari WebTransport](/quest/m4/safari-webtransport.md) - WebKit browsers return to WebTransport once WebKit 319818 ships fixed - [MSFTS convergence](/quest/m4/msfts-convergence.md) - the demultiplexed TS lane maps onto MSFTS ES-level carriage once msfts#33 settles the payload unit diff --git a/quest/m4/msfts-convergence.md b/quest/m4/msfts-convergence.md index 45af899df9..fb344dd510 100644 --- a/quest/m4/msfts-convergence.md +++ b/quest/m4/msfts-convergence.md @@ -20,7 +20,3 @@ change on either side. Update `drafts/draft-lcurley-moq-mpegts.md` and ## Required - msfts#33 (https://github.com/mondain/msfts/issues/33) settles the ES-level payload unit - -## Closes - -- [#3731](https://github.com/moq-dev/moq/issues/3731) - close this issue when the quest finishes diff --git a/rs/AGENTS.md b/rs/AGENTS.md index 2d1908d442..06c123a21c 100644 --- a/rs/AGENTS.md +++ b/rs/AGENTS.md @@ -38,7 +38,7 @@ Prefer poll. New logic is a `poll_*` with an `async` helper, not the other way a - `if let` / `let else` over a `match` whose only job is to bind. Keep `match` when both arms do work. - Derive `Serialize`/`Deserialize`; a hand-written impl is a second copy of the wire shape. Reach for `serde_with` for what the derive can't express. - Public modules with short names: `broadcast::Consumer`, not `BroadcastConsumer`. Keep `mod encoder` private and re-export flat as `encode::Encoder`. -- Workspace members and shared dependency versions live in the root `Cargo.toml`; crates reference deps via `{ workspace = true }`. +- Workspace members and shared dependency versions live in the root `Cargo.toml`; crates reference deps via `{ workspace = true }`, except internal dev-dependencies, which are path-only so releases publish (`_publish-test` enforces it). - Use newtypes and enums instead of untyped strings. - Have the language make misuse impossible: terminal operations consume `self`, cleanup in `Drop`, etc. @@ -50,7 +50,7 @@ Prefer poll. New logic is a `poll_*` with an `async` helper, not the other way a # Testing -- Tests are inline `#[cfg(test)] mod tests`. Time-dependent async tests call `tokio::time::pause()` first. +- Tests are inline `#[cfg(test)] mod tests`. Time-dependent async tests call `tokio::time::pause()` first, unless they cross real networking that can't be mocked (sockets, smoke tests); those run on the wall clock and assert lower bounds. - Run tests through `just` (nextest), not `cargo test`: nextest kills a wedged test as TIMEOUT, cargo hangs forever. A test flagged SLOW is a bug to fix, not a threshold to raise. - `just check` compiles default features only, like CI. `just rs features` (nightly) covers `--all-features` / `--no-default-features`. Keep a feature gate around the dependency, not the logic, so the logic's tests stay in the merge gate. -- Local checks compile only the host platform; `just rs windows` / `macos` must run on that OS and `just rs wasm` covers `moq-wasm`. Say plainly in the PR when platform code is uncompiled. +- Local checks compile only the host platform; PR CI runs `just rs windows` / `macos` on those hosts, and `just rs wasm` covers `moq-wasm`. diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 8eb3f06886..9083efe9a8 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.8](https://github.com/moq-dev/moq/compare/hang-v0.21.7...hang-v0.21.8) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + +## [0.21.7](https://github.com/moq-dev/moq/compare/hang-v0.21.6...hang-v0.21.7) - 2026-09-26 + +### Added + +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + ## [0.21.6](https://github.com/moq-dev/moq/compare/hang-v0.21.5...hang-v0.21.6) - 2026-09-26 ### Other diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 3a91b6cc15..6f9733a835 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.21.6" +version = "0.21.8" edition = "2024" rust-version.workspace = true diff --git a/rs/hang/src/catalog/binary.rs b/rs/hang/src/catalog/binary.rs index 7c4ec811eb..e22aa70479 100644 --- a/rs/hang/src/catalog/binary.rs +++ b/rs/hang/src/catalog/binary.rs @@ -97,6 +97,14 @@ pub struct BinaryConfig { #[serde(default)] pub jitter: Option, + /// How far this track's payloads reach the transport behind the broadcast's earliest + /// rendition, with the same meaning and encoding as + /// [`VideoConfig::delay`](crate::catalog::VideoConfig::delay). Only measured for payloads that + /// carry a capture time. + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, + /// Fields this build doesn't recognize, kept so the entry round-trips. /// /// A future [`Mode`] or [`Compression`] almost certainly comes with fields describing it, and @@ -117,6 +125,7 @@ impl BinaryConfig { mime: None, bitrate: None, jitter: None, + delay: None, extra: Default::default(), } } diff --git a/rs/hang/src/catalog/json.rs b/rs/hang/src/catalog/json.rs index 9fa0d355c2..7a7ad831b0 100644 --- a/rs/hang/src/catalog/json.rs +++ b/rs/hang/src/catalog/json.rs @@ -95,6 +95,14 @@ pub struct JsonConfig { #[serde(default)] pub jitter: Option, + /// How far this track's payloads reach the transport behind the broadcast's earliest + /// rendition, with the same meaning and encoding as + /// [`VideoConfig::delay`](crate::catalog::VideoConfig::delay). Only measured for payloads that + /// carry a capture time. + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, + /// Fields this build doesn't recognize, kept so the entry round-trips. /// /// A future [`Mode`] or [`Compression`] almost certainly comes with fields describing it, and @@ -115,6 +123,7 @@ impl JsonConfig { schema: None, bitrate: None, jitter: None, + delay: None, extra: Default::default(), } } diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 8941e3835f..d827c3efbf 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -783,14 +783,16 @@ mod test { assert_eq!(output, encoded, "encode mismatch"); } - /// Data tracks carry the same optional `bitrate` and whole-millisecond `jitter` as media. + /// Data tracks carry the same optional `bitrate` and whole-millisecond `jitter` and `delay` as + /// media. #[test] fn data_track_bitrate_and_jitter() { - let encoded = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"json":{"tracks":{"gps":{"mode":"stream","bitrate":8000,"jitter":100}}},"binary":{"tracks":{"frames":{"mode":"snapshot","bitrate":64000,"jitter":34}}}}"#; + let encoded = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"json":{"tracks":{"gps":{"mode":"stream","bitrate":8000,"jitter":100,"delay":250}}},"binary":{"tracks":{"frames":{"mode":"snapshot","bitrate":64000,"jitter":34}}}}"#; let mut gps = JsonConfig::new(Mode::Stream); gps.bitrate = Some(8_000); gps.jitter = Some(std::time::Duration::from_millis(100)); + gps.delay = Some(std::time::Duration::from_micros(249_001)); let mut frames = BinaryConfig::new(Mode::Snapshot); frames.bitrate = Some(64_000); @@ -812,6 +814,10 @@ mod test { Some(std::time::Duration::from_millis(34)) ); assert_eq!(decoded.json.tracks["gps"].bitrate, Some(8_000)); + assert_eq!( + decoded.json.tracks["gps"].delay, + Some(std::time::Duration::from_millis(250)) + ); } /// An application lists a data track in its own section by flattening a data config beside its diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index db43d6c3eb..f4bba68565 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -106,6 +106,20 @@ impl Video { self.renditions.remove(name) } + /// Iterate the renditions best first: largest picture, then highest bitrate. + /// + /// A consumer that carries one rendition takes the first it supports, so the + /// picture doesn't depend on how the tracks are named. Unknown dimensions or + /// bitrate rank below known ones, and exact ties keep name order. + pub fn ranked(&self) -> impl Iterator { + let mut ranked: Vec<_> = self.renditions.iter().collect(); + ranked.sort_by_key(|(_, config)| { + let area = u64::from(config.coded_width.unwrap_or(0)) * u64::from(config.coded_height.unwrap_or(0)); + std::cmp::Reverse((area, config.bitrate)) + }); + ranked.into_iter() + } + /// Normalize and replace the properties shared by every video rendition. pub fn set_properties(&mut self, properties: VideoProperties) -> crate::Result<()> { let properties = properties.normalized()?; @@ -282,6 +296,35 @@ mod test { use super::*; + #[test] + fn ranked_orders_by_picture_then_bitrate() { + fn rendition(size: Option<(u32, u32)>, bitrate: Option) -> VideoConfig { + let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = size.map(|(w, _)| w); + config.coded_height = size.map(|(_, h)| h); + config.bitrate = bitrate; + config + } + + let mut video = Video::default(); + // Names sort worst first, so name order alone would pick the wrong one. + video.insert("a", rendition(None, Some(9_000_000))).unwrap(); + video.insert("b", rendition(Some((640, 360)), Some(1_000_000))).unwrap(); + video.insert("c", rendition(Some((1280, 720)), None)).unwrap(); + video + .insert("d", rendition(Some((1280, 720)), Some(3_000_000))) + .unwrap(); + video + .insert("e", rendition(Some((1280, 720)), Some(3_000_000))) + .unwrap(); + video + .insert("f", rendition(Some((1920, 1080)), Some(6_000_000))) + .unwrap(); + + let names: Vec<_> = video.ranked().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names, ["f", "d", "e", "c", "b", "a"]); + } + #[test] fn label_round_trips() { let mut config = VideoConfig::new(VideoCodec::VP8); diff --git a/rs/justfile b/rs/justfile index 1412a9e447..80611a247c 100644 --- a/rs/justfile +++ b/rs/justfile @@ -578,24 +578,21 @@ fix-changed $FILES: just rs wasm-fix fi -# Runs the `#[cfg(target_os = "windows")]` code past the compiler: moq-video's -# Media Foundation capture/encode/decode and its D3D11 frames, which the Linux -# gate skips entirely. Cross-compiling can't stand in, because openh264-sys2 -# builds vendored C++ that needs an MSVC toolchain and openh264 is a -# non-optional dependency. -# -# Windows runners are throttled too hard for a per-PR gate, so nightly.yml runs -# this once a day instead. A break there lands on main rather than being caught -# in review, which is the same trade the other nightly gates make. +# Runs the `#[cfg(windows)]` code past the compiler, which the Linux gate skips +# entirely: moq-video's Media Foundation capture/encode/decode and its D3D11 +# frames, moq-nvenc's Windows loader, and the Winsock paths in moq-tokio. A +# native host rather than a cross-compile, because openh264-sys2's vendored C++ +# needs an MSVC toolchain. platform.yml runs this on every pull request and +# every push to main and dev. # # Default features rather than `--all-features`: jemalloc doesn't build on -# MSVC, while moq-video's Linux-only `nvidia` default -# are already no-ops off Linux. moq-gst is excluded because it links -# GStreamer via pkg-config, which the Windows runner doesn't have. +# MSVC, and the Linux-only features (vaapi, pipewire, io-uring) have nothing to +# compile here. moq-gst is excluded because it links GStreamer via pkg-config, +# which the runner doesn't have. # # `moq-cli/play` and `moq-cli/capture` are named explicitly because they are -# off by default, and they are the only thing that compiles the cli's own -# device and render code. +# off by default, and they are what turns on the device and render code in +# moq-video, moq-audio, and the cli itself. # Compile the whole workspace on a Windows host. Must run ON Windows. windows *args: @@ -618,25 +615,18 @@ uring *args: uring-check *args: cargo clippy --locked -p moq-uring -p moq-relay --features moq-relay/io-uring --all-targets {{ args }} -- -D warnings -# Runs the `#[cfg(target_os = "macos")]` code past the compiler: moq-video's -# VideoToolbox encode/decode and its ScreenCaptureKit / AVFoundation capture, -# plus moq-audio's ScreenCaptureKit system audio and TCC permission pre-check. -# -# Same trade as `windows`: nightly.yml runs this once a day rather than gating a -# merge on a Mac runner. The moq-video half also has a release-time backstop, -# since moq-c's `moq-c-v*` tag build compiles it on Apple Silicon. The -# moq-audio half has none: moq-c and moq-ffi take it codecs-only, so no release -# build compiles its capture backend and this recipe is the only thing that does. +# Runs the Apple-gated code past the compiler: moq-video's VideoToolbox +# encode/decode and its ScreenCaptureKit / AVFoundation capture, moq-audio's +# ScreenCaptureKit system audio and TCC permission pre-check, and moq-sock's +# BSD socket-buffer sysctl. platform.yml runs this alongside `windows`. # -# Scoped to those two crates instead of the workspace, because they hold all -# the Apple-gated code the Linux gate misses. moq-ffi (and through it the -# Swift/Kotlin/Go wrappers) already compiles on macOS in swift.yml. -# `--all-features` also compiles the off-by-default cpal hosts and PipeWire -# capture, which are Linux-only and no-ops here, so it costs nothing extra. +# The same workspace and features as `windows`, for the same reasons: moq-gst +# needs GStreamer, and the cli's `play` and `capture` features are what turn on +# the device and render code. -# Compile the Apple-only code paths. Must run ON macOS. +# Compile the whole workspace on a macOS host. Must run ON macOS. macos *args: - cargo check --locked -p moq-video -p moq-audio --all-targets --all-features {{ args }} + cargo check --locked --workspace --exclude moq-gst {{ no_fuzz }} --all-targets --features "moq-cli/play moq-cli/capture" {{ args }} # The Linux device code: moq-video's V4L2 and X11 capture and moq-audio's cpal # capture, all behind the off-by-default `capture` feature. `check-changed` runs @@ -770,7 +760,7 @@ c-tests: exit 1 fi - # Same list build.rs (moq-c.pc), CMakeLists.txt, and test/interop/interop.sh read: + # Same list nix/overlay.nix (moq-c.pc), CMakeLists.txt, and test/interop/interop.sh read: # `framework:Foo` is a linker framework flag, anything else a plain library. case "$(uname -s)" in Darwin) native_libs=rs/moq-c/native-libs/apple.txt ;; diff --git a/rs/kio/CHANGELOG.md b/rs/kio/CHANGELOG.md index 35f0c0a375..7ddc2611bb 100644 --- a/rs/kio/CHANGELOG.md +++ b/rs/kio/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1](https://github.com/moq-dev/moq/compare/kio-v0.6.0...kio-v0.6.1) - 2026-09-26 + +### Fixed + +- *(moq-net)* serve only the lite routes a request woke, and bound kio waiter lists ([#4216](https://github.com/moq-dev/moq/pull/4216)) + +### Other + +- *(kio)* keep a parked waiter that quiet lists still hold ([#4240](https://github.com/moq-dev/moq/pull/4240)) + ## [0.6.0](https://github.com/moq-dev/moq/compare/kio-v0.5.9...kio-v0.6.0) - 2026-09-23 ### Added diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index aed9d6849d..9ad60223b5 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.6.0" +version = "0.6.1" edition = "2024" rust-version.workspace = true diff --git a/rs/kio/src/weak.rs b/rs/kio/src/weak.rs index 4ebdfd43e1..2976e5e6a9 100644 --- a/rs/kio/src/weak.rs +++ b/rs/kio/src/weak.rs @@ -48,6 +48,14 @@ impl Weak { } .produce() } + + /// Read the state while another handle keeps it allocated, even once the channel + /// closed. Counts as neither a producer nor a consumer. `None` once it was dropped. + pub fn read(&self, f: impl FnOnce(&T) -> R) -> Option { + let state = self.state.upgrade()?; + let state = Ref { state: state.lock() }; + Some(f(&state)) + } } impl Default for Weak { @@ -399,6 +407,22 @@ mod test { assert!(weak.upgrade().is_none()); } + /// A closed channel stays readable through the weak handle while any handle keeps + /// it allocated, without that read reopening it or counting as demand. + #[test] + fn weak_reads_a_closed_channel() { + let producer = Producer::new(7u32); + let weak = producer.downgrade(); + let consumer = producer.consume(); + + drop(producer); + assert_eq!(weak.read(|value| *value), Some(7)); + assert!(weak.upgrade().is_none(), "reading does not reopen it"); + + drop(consumer); + assert_eq!(weak.read(|value| *value), None); + } + /// An upgrade racing the last producer's drop either loses (no handle) or wins /// (a handle that is genuinely open). It must never hand back a producer for a /// channel that the drop is about to close. diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index bdf227ecb2..8e88f96448 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.6.6" +version = "0.6.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index 32ca63360b..45bbe508b0 100644 --- a/rs/moq-archive/CHANGELOG.md +++ b/rs/moq-archive/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.8](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.7...moq-archive-v0.0.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + +## [0.0.7](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.6...moq-archive-v0.0.7) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.0.6](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.5...moq-archive-v0.0.6) - 2026-09-26 ### Other diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 99608653d3..6aaee0878b 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.6" +version = "0.0.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index 1f1528d686..6d63a2edf1 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.6...moq-audio-v0.1.7) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.5...moq-audio-v0.1.6) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net, hang, moq-mux + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.4...moq-audio-v0.1.5) - 2026-09-26 ### Other diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 8e2dd4c426..b0bdca9aaf 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.7" edition = "2024" rust-version.workspace = true @@ -19,9 +19,10 @@ categories = ["multimedia", "multimedia::audio", "multimedia::encoding"] # and Windows would pay nothing, but a default is one setting for every host. # The features exist so a consumer can pick what it wants, not to hide code from # the default compile: `just check` lints default features only, so the device -# code is compiled by nightly's `just rs features` and `just rs macos`. Workspace -# consumers opt in at the root manifest (`default-features = false`) per crate, -# which keeps cpal out of the language bindings. +# code is compiled by nightly's `just rs features` and platform.yml's +# `just rs macos`. Workspace consumers opt in at the root manifest +# (`default-features = false`) per crate, which keeps cpal out of the language +# bindings. default = ["aac"] # AAC-LC decode via symphonia. Pure Rust, so it costs no toolchain: the trade # this crate already refused for Opus. Only a subscriber to an ingest-sourced diff --git a/rs/moq-auth/CHANGELOG.md b/rs/moq-auth/CHANGELOG.md index b765d35fab..20fd5c973d 100644 --- a/rs/moq-auth/CHANGELOG.md +++ b/rs/moq-auth/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.5](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.4...moq-auth-v0.1.5) - 2026-09-27 + +### Added + +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(auth)* root public and mTLS rules at / ([#4318](https://github.com/moq-dev/moq/pull/4318)) + +### Other + +- *(auth)* run the outage grant test on the real clock ([#4291](https://github.com/moq-dev/moq/pull/4291)) + +## [0.1.4](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.3...moq-auth-v0.1.4) - 2026-09-26 + +### Fixed + +- *(auth)* keep accepted grants on fixed expiry deadlines ([#4237](https://github.com/moq-dev/moq/pull/4237)) + ## [0.1.3](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.2...moq-auth-v0.1.3) - 2026-09-26 ### Other diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 57804420f6..417398bcaa 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.3" +version = "0.1.5" edition = "2024" rust-version.workspace = true @@ -42,6 +42,7 @@ url = { workspace = true } [dev-dependencies] anyhow = { workspace = true } moq-auth = { path = ".", features = ["client", "serve"] } +moq-token = "0.7" tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } wiremock = "0.6" diff --git a/rs/moq-auth/src/claims.rs b/rs/moq-auth/src/claims.rs index d49a252f1a..2a64714a0b 100644 --- a/rs/moq-auth/src/claims.rs +++ b/rs/moq-auth/src/claims.rs @@ -66,7 +66,7 @@ impl Scope { /// /// Produced by [`Claims::authorize`]. `**` grants the path itself and everything /// beneath it; the empty pattern grants exactly the path. The reference server's -/// policy uses the same pair for anonymous and mTLS grants. +/// policy holds its anonymous and mTLS rules as this pair too, authorized at `/`. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Permissions { /// Patterns the holder may subscribe to, relative to the authorized path. @@ -105,7 +105,9 @@ impl Permissions { /// Legacy `moq-token` claims are read too: each `put`/`get` prefix `p` is the subtree /// `p/**`. Claims that only grant subtrees are written that way, so every published /// verifier accepts them; anything else is written as `publish`/`subscribe`, which an -/// older verifier refuses rather than misreads. Any other field fails verification. +/// older verifier refuses rather than misreads. The registered `iss`, `sub`, and `jti` +/// claims are read and ignored; any other field fails verification, since it might +/// narrow the grant, and a misspelled `root` would otherwise widen it. #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(try_from = "crate::wire::Claims", into = "crate::wire::Claims")] #[non_exhaustive] @@ -127,6 +129,10 @@ pub struct Claims { /// The issued time of the token as a unix timestamp (`iat`). pub issued: Option, + + /// The time before which the token is refused, as a unix timestamp (`nbf`). + /// Enforced by [`Key::verify`](crate::Key::verify). + pub not_before: Option, } impl Claims { @@ -246,6 +252,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, } } diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index dc05869365..12484d3e5f 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -443,51 +443,83 @@ mod tests { } #[tokio::test] - async fn a_grant_within_clock_skew_stays_live() { + async fn an_expired_grant_is_refused() { tokio::time::pause(); let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); let client = clock_server(Log::default(), grant, false).await; + assert!(matches!(client.connect(request()).await, Err(Error::GrantExpired))); + } + + #[tokio::test] + async fn a_grant_closes_at_its_expiry() { + tokio::time::pause(); + // Whole seconds, as the grant crosses the wire, so the client sees this exact instant. + // An hour out, so a slow runner cannot expire it before `connect` answers. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let expires = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() + 3600); + let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); + grant.expires = Some(expires); + let client = clock_server(Log::default(), grant, false).await; + + // The client reads both clocks somewhere inside `connect`, so bracket it: the + // bounds hold however long it takes. + let (wall, tick) = (SystemTime::now(), tokio::time::Instant::now()); let consumer = client.connect(request()).await.unwrap(); + let earliest = tick + expires.duration_since(SystemTime::now()).unwrap(); + let latest = tokio::time::Instant::now() + expires.duration_since(wall).unwrap(); - tokio::time::sleep(Duration::from_millis(500)).await; + // A millisecond either side for Tokio's timer resolution. + let tolerance = Duration::from_millis(1); assert!( - tokio::time::timeout(Duration::from_millis(100), consumer.closed()) + tokio::time::timeout_at(earliest - tolerance, consumer.closed()) .await .is_err(), - "still live inside the skew window" + "live until its expiry" ); - - let reason = tokio::time::timeout(crate::grant::CLOCK_SKEW + Duration::from_secs(1), consumer.closed()) + let reason = tokio::time::timeout_at(latest + tolerance, consumer.closed()) .await - .expect("expired once the skew window ended"); + .expect("closed at its expiry, not later"); assert_eq!(reason, Reason::Expired); } + /// On the real clock: a paused one jumps to the expiry timer whenever the runtime + /// waits on a socket, and macOS delivers loopback asynchronously, so the lease can + /// expire and drop the re-check before the server sees it. 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_grant_until_expires() { - tokio::time::pause(); + // Whole seconds, as the grant crosses the wire, so the client 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 log = Log::default(); - let client = clock_server( - log.clone(), - grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))), - false, - ) + let server = server(log.clone(), move |request| match request.event { + Event::Connect => { + let mut grant = grant(None, Some(Duration::from_secs(1))); + grant.expires = Some(expires); + ResponseTemplate::new(200).set_body_json(grant) + } + Event::Revalidate => ResponseTemplate::new(503), + Event::End { .. } => ResponseTemplate::new(200), + }) .await; - let consumer = client.connect(request()).await.unwrap(); + let consumer = client(&server).connect(request()).await.unwrap(); - tokio::time::sleep(Duration::from_millis(1500)).await; - assert!(log.revalidates() >= 1, "re-checks happened"); + let reason = tokio::time::timeout(Duration::from_secs(10), consumer.closed()) + .await + .expect("expired"); + assert_eq!(reason, Reason::Expired); + assert!(SystemTime::now() >= expires, "an outage must not close before expires"); + assert!( + log.revalidates() >= 1, + "no re-check reached the server during the outage" + ); assert_eq!( consumer.grant().publish, patterns(&["**"]), "the grant stands through the outage" ); - - let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed()) - .await - .expect("expired"); - assert_eq!(reason, Reason::Expired); assert!(matches!( log.end().await.event, Event::End { diff --git a/rs/moq-auth/src/error.rs b/rs/moq-auth/src/error.rs index 9ea9e5234e..fcd36d3496 100644 --- a/rs/moq-auth/src/error.rs +++ b/rs/moq-auth/src/error.rs @@ -90,6 +90,9 @@ pub enum Error { #[error("token has expired")] TokenExpired, + #[error("token is not valid yet")] + TokenNotYetValid, + #[error(transparent)] Pattern(#[from] moq_pattern::InvalidPattern), @@ -99,6 +102,9 @@ pub enum Error { #[error("grant asks to be revalidated but never expires")] UnboundedRevalidate, + #[error("session limits need a revalidate cadence, which ages out the slots of a relay that died")] + LimitsWithoutRevalidate, + #[error("grant asks to be revalidated at no interval")] ZeroRevalidate, diff --git a/rs/moq-auth/src/grant.rs b/rs/moq-auth/src/grant.rs index 954b60aae1..dd774fd52e 100644 --- a/rs/moq-auth/src/grant.rs +++ b/rs/moq-auth/src/grant.rs @@ -1,21 +1,9 @@ use moq_pattern::Patterns; use serde::{Deserialize, Serialize}; use serde_with::{DurationSeconds, TimestampSeconds, serde_as}; +use std::collections::BTreeMap; use std::time::{Duration, SystemTime}; -/// A grant that expired this recently still stands: the auth server's clock may run behind. -pub(crate) const CLOCK_SKEW: Duration = Duration::from_secs(5); - -/// How long until `at`. A deadline up to [`CLOCK_SKEW`] in the past still has the -/// remaining window; anything older is zero. Future deadlines are unchanged, so a -/// grant that expires in ten seconds still expires in ten seconds. -fn until(at: SystemTime) -> Duration { - match at.duration_since(SystemTime::now()) { - Ok(remaining) => remaining, - Err(late) => CLOCK_SKEW.saturating_sub(late.duration()), - } -} - /// What a session may do, as the auth server answered. /// /// A 2xx carrying one of these admits; anything else refuses. A grant that names @@ -40,6 +28,12 @@ pub struct Grant { /// server aliases a slug to a canonical id. Absent means the dialed path. pub root: Option, + /// Subtrees the session reads from elsewhere: each path, relative to the root, + /// resolves at the absolute path it maps to. Read-only: nothing is published + /// beneath one. The patterns still name the path relative to the root. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub mounts: BTreeMap, + /// When the session closes, as unix seconds. #[serde_as(as = "Option>")] pub expires: Option, @@ -67,15 +61,15 @@ impl Grant { } } - /// Snapshot the expiry on Tokio's clock, allowing five seconds of past clock skew. + /// Snapshot the expiry on Tokio's clock; one already past is now. #[cfg(feature = "tokio")] pub fn deadline(&self) -> Option { - self.expires.map(|at| tokio::time::Instant::now() + until(at)) + let remaining = self.expires?.duration_since(SystemTime::now()).unwrap_or_default(); + Some(tokio::time::Instant::now() + remaining) } /// Refuse a grant that admits nothing, asks to be revalidated without a bound or - /// at no interval, or has already expired. A few seconds of clock skew are - /// tolerated so an auth server whose clock runs behind still admits. + /// at no interval, or has already expired. pub fn validate(&self) -> crate::Result<()> { if self.publish.is_empty() && self.subscribe.is_empty() { return Err(crate::Error::UselessGrant); @@ -87,7 +81,7 @@ impl Grant { if self.revalidate.is_some_and(|cadence| cadence.is_zero()) { return Err(crate::Error::ZeroRevalidate); } - if self.expires.is_some_and(|expires| until(expires).is_zero()) { + if self.expires.is_some_and(|expires| expires <= SystemTime::now()) { return Err(crate::Error::GrantExpired); } Ok(()) @@ -108,6 +102,7 @@ mod tests { publish: patterns(&["alice/**"]), subscribe: patterns(&["**"]), root: Some("pid/room".into()), + mounts: BTreeMap::new(), expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)), revalidate: Some(Duration::from_secs(60)), tier: Some("websocket".into()), @@ -129,6 +124,7 @@ mod tests { publish: patterns(&["alice/**"]), subscribe: patterns(&["**"]), root: Some("pid/room".into()), + mounts: BTreeMap::new(), expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)), revalidate: Some(Duration::from_secs(60)), tier: Some("websocket".into()), @@ -140,6 +136,15 @@ mod tests { ); } + #[test] + fn mounts_round_trip_as_an_object() { + let mut grant = Grant::new(Patterns::new(), patterns(&["**"])); + grant.mounts.insert(".svc".into(), ".svc/pid".into()); + let json = serde_json::to_string(&grant).unwrap(); + assert_eq!(json, r#"{"subscribe":["**"],"mounts":{".svc":".svc/pid"}}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), grant); + } + #[test] fn empty_fields_are_omitted_and_defaulted() { let grant = Grant::new(patterns(&["**"]), Patterns::new()); @@ -157,10 +162,11 @@ mod tests { grant.revalidate = Some(Duration::from_secs(1)); assert!(matches!(grant.validate(), Err(crate::Error::UnboundedRevalidate))); - grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); - grant.validate().unwrap(); + // Exact: no grace for an auth server whose clock runs behind. + grant.expires = Some(SystemTime::now()); + assert!(matches!(grant.validate(), Err(crate::Error::GrantExpired))); - grant.expires = Some(SystemTime::now() - CLOCK_SKEW - Duration::from_secs(1)); + grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); assert!(matches!(grant.validate(), Err(crate::Error::GrantExpired))); grant.expires = Some(SystemTime::now() + Duration::from_secs(60)); @@ -169,4 +175,24 @@ mod tests { grant.revalidate = Some(Duration::ZERO); assert!(matches!(grant.validate(), Err(crate::Error::ZeroRevalidate))); } + + #[cfg(feature = "tokio")] + #[tokio::test(start_paused = true)] + async fn deadline_is_the_exact_expiry() { + let start = tokio::time::Instant::now(); + let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); + assert_eq!(grant.deadline(), None); + + grant.expires = Some(SystemTime::now() + Duration::from_secs(10)); + let deadline = grant.deadline().unwrap(); + assert!(deadline <= start + Duration::from_secs(10), "not later than the expiry"); + assert!(deadline > start + Duration::from_secs(9)); + + grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); + assert_eq!( + grant.deadline(), + Some(start), + "a past expiry is now, not a grace window" + ); + } } diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index 260b0a462b..e05213d051 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -549,14 +549,13 @@ impl Key { Ok(jsonwebtoken::decode::(token, self.to_decoding_key()?, &validation)?.claims) } - /// Verify a token's signature and this crate's strict claims, expiry, and key scope. + /// Verify a token's signature and this crate's strict claims, expiry, not-before, and key scope. + /// + /// Scoping the claims to a connection path is a separate step; see + /// [`Claims::authorize`]. pub fn verify(&self, token: &str) -> crate::Result { let claims: Claims = self.decode(token)?; - if let Some(exp) = claims.expires - && exp < std::time::SystemTime::now() - { - return Err(crate::Error::TokenExpired); - } + validate_times(&claims, std::time::SystemTime::now())?; claims.validate()?; self.validate_scope(&claims)?; Ok(claims) @@ -611,6 +610,17 @@ impl Key { } } +/// Refuse claims expired at `now` (`exp <= now`) or not yet valid (`nbf > now`), as `jose` does. +fn validate_times(claims: &Claims, now: std::time::SystemTime) -> crate::Result<()> { + if claims.expires.is_some_and(|exp| exp <= now) { + return Err(crate::Error::TokenExpired); + } + if claims.not_before.is_some_and(|nbf| nbf > now) { + return Err(crate::Error::TokenNotYetValid); + } + Ok(()) +} + /// Serialize bytes as base64url without padding fn serialize_base64url(bytes: &[u8], serializer: S) -> Result where @@ -691,6 +701,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, } } @@ -937,6 +948,7 @@ mod tests { subscribe: patterns(&[]), expires: None, issued: None, + not_before: None, }; let result = key.sign(&invalid_claims); @@ -993,6 +1005,95 @@ mod tests { assert!(result.is_ok()); } + /// Sign an arbitrary payload with `key`, bypassing [`Claims`], as another issuer might. + fn sign_raw(key: &Key, payload: serde_json::Value) -> String { + let mut header = Header::new(key.algorithm.into()); + header.kid = key.kid.as_ref().map(ToString::to_string); + jsonwebtoken::encode(&header, &payload, key.to_encoding_key().unwrap()).unwrap() + } + + /// An issuer's bookkeeping is read and dropped; anything else is refused by name, + /// since it might narrow the grant, and a misspelled `root` would widen it to + /// everything. + #[test] + fn test_key_verify_only_registered_claims() { + let key = create_test_key(); + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let token = sign_raw( + &key, + serde_json::json!({"root": "room", "publish": ["**"], "iss": "api", "sub": "alice", "jti": "1", "iat": now}), + ); + assert_eq!(key.verify(&token).unwrap().root, "room"); + + for (claim, payload) in [ + ("rooot", serde_json::json!({"rooot": "room/123", "publish": ["**"]})), + ( + "user_id", + serde_json::json!({"root": "room", "publish": ["**"], "user_id": 7}), + ), + ( + "cluster", + serde_json::json!({"root": "room", "put": [""], "cluster": true}), + ), + ] { + let err = key.verify(&sign_raw(&key, payload)).unwrap_err().to_string(); + assert!(err.contains(&format!("`{claim}`")), "{claim}: {err}"); + } + + // No audience is configured, so there is nothing to check one against. + let token = sign_raw( + &key, + serde_json::json!({"root": "room", "publish": ["**"], "aud": "relay"}), + ); + assert!(key.verify(&token).is_err()); + } + + #[test] + fn test_key_verify_enforces_not_before() { + let key = create_test_key(); + let at = |offset: i64| { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + sign_raw( + &key, + serde_json::json!({"root": "room", "publish": ["**"], "nbf": now + offset}), + ) + }; + assert!(key.verify(&at(-60)).is_ok()); + assert!(matches!(key.verify(&at(3600)), Err(crate::Error::TokenNotYetValid))); + } + + /// `exp` is refused at the instant itself and `nbf` accepted at it, matching `jose`. + #[test] + fn validate_times_at_the_boundary() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000); + let second = Duration::from_secs(1); + + let at = |expires: Option, not_before: Option| { + let mut claims = create_test_claims(); + claims.expires = expires; + claims.not_before = not_before; + validate_times(&claims, now) + }; + + assert!(at(Some(now + second), None).is_ok()); + assert!(matches!(at(Some(now), None), Err(crate::Error::TokenExpired))); + assert!(matches!(at(Some(now - second), None), Err(crate::Error::TokenExpired))); + + assert!(at(None, Some(now)).is_ok()); + assert!(at(None, Some(now - second)).is_ok()); + assert!(matches!( + at(None, Some(now + second)), + Err(crate::Error::TokenNotYetValid) + )); + } + #[test] fn test_key_verify_expired_token() { let key = create_test_key(); @@ -1013,6 +1114,7 @@ mod tests { subscribe: patterns(&["**"]), expires: None, issued: None, + not_before: None, }; let token = key.sign(&claims).unwrap(); @@ -1032,6 +1134,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, }; let token = key.sign(&original_claims).unwrap(); diff --git a/rs/moq-auth/src/request.rs b/rs/moq-auth/src/request.rs index badff0d173..2e7d20504e 100644 --- a/rs/moq-auth/src/request.rs +++ b/rs/moq-auth/src/request.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use serde_with::base64::{Base64, UrlSafe}; +use serde_with::formats::Unpadded; use serde_with::{DurationSecondsWithFrac, TimestampSeconds, serde_as}; use std::net::SocketAddr; use std::time::{Duration, SystemTime}; @@ -8,8 +10,8 @@ use crate::lease::Reason; /// Everything a relay knows about a session, sent to the auth server on every event. /// /// Nothing is parsed on the relay's behalf: the server keys policy on the raw -/// [`path`](Self::path) and [`query`](Self::query), so no query parameter is special -/// and a credential can be whatever the server understands. The same shape carries +/// [`path`](Self::path), [`query`](Self::query), and [`token`](Self::token), so no +/// query parameter is special and a credential can be whatever the server understands. The same shape carries /// every [`Event`]; an `end` adds what the session did. #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -46,6 +48,9 @@ pub struct Request { /// The raw query string, without the leading `?`. pub query: Option, + /// The credential a moq-transport client presented in its SETUP. + pub token: Option, + /// The direction the client declared at SETUP; absent means both. pub role: Option, @@ -73,12 +78,31 @@ impl Request { alpn: None, path: path.into(), query: None, + token: None, role: None, tls: None, } } } +/// A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Token { + /// The moq-transport Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token bytes, base64url without padding on the wire. + #[serde_as(as = "Base64")] + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format negotiated out of band; `moq auth serve` reads it as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token. + pub const CAT: u64 = 0x1; +} + /// The lifecycle moment a [`Request`] reports. #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -268,6 +292,34 @@ mod tests { ); } + /// The exact bytes `js/auth/src/contract.test.ts` parses: the value is base64url, so + /// bytes that are not text survive the JSON unchanged. + #[test] + fn a_setup_token_serializes_as_base64url() { + let mut request = Request::new("relay-1", Transport::Quic, "/demo/room"); + request.id = "00ff".into(); + request.token = Some(Token { + kind: Token::CAT, + value: vec![0x00, 0xfb, 0xff], + }); + let json = serde_json::to_string(&request).unwrap(); + assert_eq!( + json, + r#"{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), request); + + // `js/auth` refuses the same malformed values. + for value in ["A", "AB", "APv_A", "AP+/"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_err(), "{value}"); + } + for value in ["", "AA", "AAA", "AAAA", "AQ", "AAE"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_ok(), "{value}"); + } + } + #[test] fn gateway_transports_round_trip_on_the_wire() { for (transport, text) in [ diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index 4ba2fdcafa..b22425c561 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -2,8 +2,10 @@ //! //! [`Policy`] decides a [`Request`] the way `--auth-key`, `--auth-key-dir`, and //! `--auth-public` did on the relay, plus an explicit grant for mTLS peers and live -//! session caps. [`Server`] serves it on a TCP or unix listener as `POST /`. `moq auth -//! serve` is the CLI; the relay's tests run against it in process. +//! session caps. Its anonymous and mTLS rules are rooted at `/`, like a token with an +//! empty root, and authorized at the dialed path the same way. [`Server`] serves it +//! on a TCP or unix listener as `POST /`. `moq auth serve` is the CLI; the relay's +//! tests run against it in process. use std::collections::HashMap; use std::net::IpAddr; @@ -18,7 +20,7 @@ use axum::routing::post; use axum::{Json, Router}; use tokio::time::Instant; -use crate::{Event, Grant, Key, KeyId, KeySet, Permissions, Request}; +use crate::{Event, Grant, Key, KeyId, KeySet, Permissions, Request, Token}; /// Where the signing keys a `jwt` is verified against come from. Read per request, /// so a rotated file takes effect without a restart. @@ -39,6 +41,7 @@ pub enum Keys { /// a relay that dies without sending `end` holds its slots until they age out after /// two cadences, a restart empties the table until the fleet's next cadence refills /// it, and a session admitted while a live one's slot was missing stays over the cap. +/// The cadence is [`Policy::revalidate`]; without one, nothing ages out. /// /// `#[non_exhaustive]`, so start from [`Limits::default`] and set the fields. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -51,44 +54,44 @@ pub struct Limits { } /// The decisions the server answers with, evaluated in order and stopping at the -/// first that applies: a `jwt` in the query, then a verified certificate, then the -/// anonymous rules. A malformed or expired token is a refusal, never a fall through. +/// first that applies: a JWT, then a verified certificate, then the anonymous rules. +/// A malformed or expired token is a refusal, never a fall through. +/// +/// The anonymous and mTLS rules name what they grant from `/`, like a token with an +/// empty root: `anon/**` admits a session dialed at `/`, `/anon`, or `/anon/room`, +/// each scoped to `anon/`, and refuses one dialed at `/other`. +/// +/// The JWT is the `jwt` query parameter or a moq-transport SETUP token of type 0 +/// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is verified +/// once when they are the same JWT and refused when they differ, as is a SETUP token +/// of any other type, and one presenting a JWT alongside a +/// certificate: neither can safely win, since a certificate would override a JWT +/// meant to narrow it, and a JWT would narrow or refuse a peer by accident. /// /// `#[non_exhaustive]`, so start from [`Policy::default`] and set the fields. #[derive(Clone, Debug)] #[non_exhaustive] +#[derive(Default)] pub struct Policy { /// The keys a `jwt` is verified against; `None` refuses every token. pub keys: Option, - /// What an anonymous session is granted; empty refuses it. + /// What an anonymous session is granted, rooted at `/`; empty refuses it. pub public: Permissions, - /// What a session presenting a verified certificate is granted; empty refuses it. + /// What a session presenting a verified certificate is granted, rooted at `/`; + /// empty refuses it. pub mtls: Permissions, /// The tier stamped on every grant. pub tier: Option, - /// How often the relay re-checks each grant. - pub revalidate: Duration, + /// How often the relay re-checks each grant; `None` never re-checks. The contract + /// refuses a cadence without a bound, so set [`expires`](Self::expires) with it. + pub revalidate: Option, /// How long a grant with no bound of its own lasts: an anonymous session, a token - /// without `exp`, a certificate without one. - pub expires: Duration, + /// without `exp`, a certificate without one. `None` leaves those unbounded. + pub expires: Option, /// Live session caps. pub limits: Limits, } -impl Default for Policy { - fn default() -> Self { - Self { - keys: None, - public: Permissions::default(), - mtls: Permissions::default(), - tier: None, - revalidate: Duration::from_secs(60), - expires: Duration::from_secs(24 * 60 * 60), - limits: Limits::default(), - } - } -} - /// Why a session was refused; the body of the 403. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] @@ -103,7 +106,7 @@ pub enum Refusal { InvalidToken(String), #[error("the token root `{root}` does not overlap the dialed path `{path}`")] RootMismatch { root: String, path: String }, - #[error("the token grants no access at `{path}`")] + #[error("nothing is granted at `{path}`")] NoAccess { path: String }, #[error("a certificate was presented but nothing is granted to certificates")] NoMtlsGrant, @@ -113,12 +116,22 @@ pub enum Refusal { TokenLimit, #[error("too many live sessions from this address")] RemoteLimit, + #[error("a SETUP token and a different `jwt` query were presented; present one")] + TwoTokens, + #[error("SETUP token type {0:#x} is not supported; only type 0 (a JWT) is")] + UnsupportedToken(u64), + #[error("both a JWT and a client certificate were presented; present one")] + TokenAndCertificate, } impl Policy { /// Decide `request` by the policy alone, ignoring session limits. pub async fn decide(&self, request: &Request) -> Result { - let (permissions, expires) = if let Some(jwt) = token(request) { + let jwt = jwt(request)?; + if jwt.is_some() && request.tls.is_some() { + return Err(Refusal::TokenAndCertificate); + } + let (permissions, expires) = if let Some(jwt) = jwt { let claims = self.verify(jwt).await?; let permissions = claims.authorize(&request.path).map_err(|err| match err { crate::Error::RootMismatch(path) => Refusal::RootMismatch { @@ -133,18 +146,17 @@ impl Policy { if self.mtls.is_empty() { return Err(Refusal::NoMtlsGrant); } - (self.mtls.clone(), peer.expires) + (authorize(&self.mtls, &request.path)?, peer.expires) } else { if self.public.is_empty() { return Err(Refusal::NoPublicGrant); } - (self.public.clone(), None) + (authorize(&self.public, &request.path)?, None) }; let mut grant = Grant::new(permissions.publish, permissions.subscribe); - // The contract refuses a cadence without a bound, so every grant carries one. - grant.expires = Some(expires.unwrap_or_else(|| SystemTime::now() + self.expires)); - grant.revalidate = Some(self.revalidate); + grant.expires = expires.or_else(|| self.expires.map(|bound| SystemTime::now() + bound)); + grant.revalidate = self.revalidate; grant.tier = self.tier.clone(); Ok(grant) } @@ -180,9 +192,39 @@ impl Policy { } } +/// Authorize rules rooted at `/` at the dialed `path`, exactly as a token with an +/// empty root would be. +fn authorize(rules: &Permissions, path: &str) -> Result { + let claims = crate::Claims { + publish: rules.publish.clone(), + subscribe: rules.subscribe.clone(), + ..Default::default() + }; + // An empty root overlaps every path, so the only refusal is reaching nothing. + claims.authorize(path).map_err(|_| Refusal::NoAccess { + path: crate::path::normalize(path), + }) +} + +/// The JWT the request presents: its SETUP token, or else its `jwt` query parameter. +/// Both at once count as one only when they are the same JWT. +fn jwt(request: &Request) -> Result, Refusal> { + match (&request.token, query_jwt(request)) { + // A client that offers a version without in-band auth copies its SETUP token + // into the URL too, so the same JWT arrives twice. + (Some(token), Some(jwt)) if token.kind == Token::OUT_OF_BAND && token.value == jwt.as_bytes() => Ok(Some(jwt)), + (Some(_), Some(_)) => Err(Refusal::TwoTokens), + (Some(token), None) if token.kind == Token::OUT_OF_BAND => std::str::from_utf8(&token.value) + .map(Some) + .map_err(|_| Refusal::InvalidToken("the SETUP token is not UTF-8".into())), + (Some(token), None) => Err(Refusal::UnsupportedToken(token.kind)), + (None, jwt) => Ok(jwt), + } +} + /// The `jwt` query parameter, when the request carries a non-empty one. The last one /// wins, as it did on the relay, so a client that appends a fresh token is believed. -fn token(request: &Request) -> Option<&str> { +fn query_jwt(request: &Request) -> Option<&str> { let query = request.query.as_deref()?; // Borrow rather than decode: a JWT is base64url and never needs unescaping. query @@ -223,7 +265,8 @@ impl Sessions { slot.seen = Instant::now(); return Ok(()); } - let token = token(request).map(hash); + // Decided before counting, so the credential is already known to be one JWT. + let token = jwt(request).ok().flatten().map(hash); let remote = remote(request); if let (Some(cap), Some(token)) = (limits.token, token) && self.slots.values().filter(|slot| slot.token == Some(token)).count() >= cap @@ -251,7 +294,7 @@ impl Sessions { /// which survivor to revoke would be an accident of arrival order. fn revalidate(&mut self, request: &Request) { let slot = self.slots.entry(request.id.clone()).or_insert_with(|| Slot { - token: token(request).map(hash), + token: jwt(request).ok().flatten().map(hash), remote: remote(request), seen: Instant::now(), }); @@ -279,12 +322,28 @@ pub struct Server { } impl Server { - /// A server answering with `policy`. - pub fn new(policy: Policy) -> Self { - Self { + /// A server answering with `policy`, refusing one that asks for a re-check without + /// a bound, or caps sessions without the re-check that ages out a dead relay's. + pub fn new(policy: Policy) -> crate::Result { + if policy.revalidate.is_some() && policy.expires.is_none() { + return Err(crate::Error::UnboundedRevalidate); + } + let limited = policy.limits.token.is_some() || policy.limits.remote.is_some(); + if limited && policy.revalidate.is_none() { + return Err(crate::Error::LimitsWithoutRevalidate); + } + Ok(Self { policy: Arc::new(policy), sessions: Default::default(), - } + }) + } + + /// The cadence the session table ages out by, or `None` when no limit needs a table. + fn cadence(&self) -> Option { + let limits = self.policy.limits; + (limits.token.is_some() || limits.remote.is_some()) + .then_some(self.policy.revalidate) + .flatten() } /// Answer one event: the grant, or why the session is refused. @@ -292,16 +351,20 @@ impl Server { match request.event { Event::Connect => { let grant = self.policy.decide(request).await?; - let mut sessions = self.sessions.lock().unwrap(); - sessions.sweep(self.policy.revalidate); - sessions.connect(request, self.policy.limits)?; + if let Some(cadence) = self.cadence() { + let mut sessions = self.sessions.lock().unwrap(); + sessions.sweep(cadence); + sessions.connect(request, self.policy.limits)?; + } Ok(Some(grant)) } Event::Revalidate => { let grant = self.policy.decide(request).await?; - let mut sessions = self.sessions.lock().unwrap(); - sessions.sweep(self.policy.revalidate); - sessions.revalidate(request); + if let Some(cadence) = self.cadence() { + let mut sessions = self.sessions.lock().unwrap(); + sessions.sweep(cadence); + sessions.revalidate(request); + } Ok(Some(grant)) } Event::End { .. } => { @@ -395,7 +458,7 @@ mod tests { /// The server behind a client, with a signal for each `end` it has handled. async fn serve(policy: Policy) -> (Client, Arc) { - let server = Server::new(policy); + let server = Server::new(policy).unwrap(); let ended = Arc::new(tokio::sync::Notify::new()); let router = Router::new() .route( @@ -440,7 +503,7 @@ mod tests { assert_eq!(grant.publish, patterns(&["alice/**"])); assert_eq!(grant.subscribe, patterns(&["**"])); assert_eq!(grant.expires, Some(exp)); - assert_eq!(grant.revalidate, Some(Duration::from_secs(60))); + assert_eq!(grant.revalidate, None); assert_eq!(grant.tier.as_deref(), Some("gold")); assert_eq!(grant.root, None); } @@ -450,7 +513,7 @@ mod tests { let (dir, key) = key_dir(); let policy = Policy { keys: Some(Keys::Dir(dir.path().into())), - expires: Duration::from_secs(3600), + expires: Some(Duration::from_secs(3600)), ..Default::default() }; let jwt = sign(&key, "demo", &["**"], &[], None); @@ -598,8 +661,9 @@ mod tests { remote: Some(1), ..Default::default() }, - ..Default::default() - }); + ..limited() + }) + .unwrap(); for transport in [Transport::Rtmp, Transport::Srt, Transport::WebRtc] { let mut first = request("/room"); first.transport = transport; @@ -616,6 +680,91 @@ mod tests { } } + fn with_setup_token(mut request: Request, kind: u64, value: &str) -> Request { + request.token = Some(Token { + kind, + value: value.as_bytes().to_vec(), + }); + request + } + + /// A type-0 SETUP token is the deployment's JWT, verified exactly like `?jwt=`. + #[tokio::test] + async fn a_type_zero_setup_token_is_a_jwt() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["alice/**"], &[], None); + let grant = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &jwt)) + .await + .unwrap(); + assert_eq!(grant.publish, patterns(&["alice/**"])); + + // And refused like one: a stranger's key never falls through to public. + let stranger = Key::generate(Algorithm::HS256, Some(KeyId::decode("kid1").unwrap())).unwrap(); + let forged = sign(&stranger, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &forged)) + .await + .unwrap_err(); + assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); + } + + #[tokio::test] + async fn a_setup_token_of_another_type_is_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + public: rules(&["**"], &["**"]), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::CAT, &jwt)) + .await + .unwrap_err(); + assert_eq!(err, Refusal::UnsupportedToken(Token::CAT)); + assert_eq!( + err.to_string(), + "SETUP token type 0x1 is not supported; only type 0 (a JWT) is" + ); + } + + /// The same JWT in the SETUP token and the query is one credential, evaluated once. + #[tokio::test] + async fn a_setup_token_equal_to_the_query_jwt_is_admitted() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let request = with_setup_token(with_token(request("/demo"), &jwt), Token::OUT_OF_BAND, &jwt); + let grant = policy.decide(&request).await.unwrap(); + assert_eq!(grant.publish, patterns(&["**"])); + } + + /// Two different credentials would leave the server guessing which one the client meant. + #[tokio::test] + async fn a_setup_token_and_a_different_query_jwt_are_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let setup = sign(&key, "demo", &["**"], &[], None); + let query = sign(&key, "demo", &[], &["**"], None); + let different = with_setup_token(with_token(request("/demo"), &query), Token::OUT_OF_BAND, &setup); + assert_eq!(policy.decide(&different).await.unwrap_err(), Refusal::TwoTokens); + + // The same bytes under another token type are still a second credential. + let other_type = with_setup_token(with_token(request("/demo"), &setup), Token::CAT, &setup); + assert_eq!(policy.decide(&other_type).await.unwrap_err(), Refusal::TwoTokens); + } + #[tokio::test] async fn the_last_jwt_in_the_query_wins() { let (dir, key) = key_dir(); @@ -629,7 +778,7 @@ mod tests { // relay; a trailing empty value does not blank it out. let mut request = request("/demo"); request.query = Some(format!("a=1&jwt=stale&jwt={fresh}&jwt=")); - assert_eq!(token(&request), Some(fresh.as_str())); + assert_eq!(query_jwt(&request), Some(fresh.as_str())); assert!(policy.decide(&request).await.is_ok()); request.query = Some(format!("jwt={fresh}&jwt=stale")); @@ -637,7 +786,7 @@ mod tests { assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); request.query = Some("jwt=&b=2".into()); - assert_eq!(token(&request), None); + assert_eq!(query_jwt(&request), None); } #[tokio::test] @@ -657,14 +806,36 @@ mod tests { assert_eq!(grant.publish, patterns(&["**"])); assert_eq!(grant.expires, Some(not_after)); - // A certificate without a bound gets the default one. + // A certificate without a bound gets none by default, like 0.14. let grant = policy.decide(&with_peer(request("/"), None)).await.unwrap(); - assert!(grant.expires.unwrap() <= SystemTime::now() + policy.expires); + assert_eq!(grant.expires, None); // The certificate does not stand in for a public grant. assert_eq!(policy.decide(&request("/")).await.unwrap_err(), Refusal::NoPublicGrant); } + /// A certificate would override a JWT meant to narrow it, and a JWT would narrow or + /// refuse a peer by accident, so presenting both is refused, even with a bad JWT. + #[tokio::test] + async fn a_jwt_and_a_certificate_together_are_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + mtls: rules(&["**"], &["**"]), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + for request in [ + with_peer(with_token(request("/demo"), &jwt), None), + with_peer(with_token(request("/demo"), "garbage"), None), + with_peer(with_setup_token(request("/demo"), Token::OUT_OF_BAND, &jwt), None), + ] { + assert_eq!(policy.decide(&request).await.unwrap_err(), Refusal::TokenAndCertificate); + } + assert!(policy.decide(&with_peer(request("/demo"), None)).await.is_ok()); + assert!(policy.decide(&with_token(request("/demo"), &jwt)).await.is_ok()); + } + #[tokio::test] async fn anonymous_gets_the_public_rules() { let policy = Policy { @@ -674,7 +845,48 @@ mod tests { let grant = policy.decide(&request("/")).await.unwrap(); assert_eq!(grant.subscribe, patterns(&["anon/**"])); assert!(grant.publish.is_empty()); - assert!(grant.expires.is_some()); + // Never re-checked or closed by default, like 0.14. + assert_eq!(grant.expires, None); + assert_eq!(grant.revalidate, None); + } + + /// The rules are rooted at `/`, not at the dialed path: `anon/**` scopes a session + /// dialed anywhere to `anon/`, and refuses one dialed outside it. Bare `**` reads + /// the same either way, which is how rooting them at the dialed path went unnoticed. + #[tokio::test] + async fn public_and_mtls_rules_are_rooted_at_slash() { + let policy = Policy { + public: rules(&["anon/**"], &["anon/**", "*/chat"]), + mtls: rules(&["origin/*"], &["origin/*"]), + ..Default::default() + }; + + for (path, publish, subscribe) in [ + ("/", &["anon/**"][..], &["anon/**", "*/chat"][..]), + ("/anon", &["**"], &["**", "chat"]), + ("/anon/room", &["**"], &["**"]), + ("/rooms", &[], &["chat"]), + ] { + let grant = policy.decide(&request(path)).await.unwrap(); + assert_eq!(grant.publish, patterns(publish), "{path}"); + assert_eq!(grant.subscribe, patterns(subscribe), "{path}"); + assert_eq!(grant.root, None, "{path}"); + } + + // Before, `/rooms/123` got `rooms/123/anon/**`: into any room, anonymously. + for path in ["/rooms/123", "/other/room", "/anonymous/room"] { + assert_eq!( + policy.decide(&request(path)).await.unwrap_err(), + Refusal::NoAccess { + path: path.trim_start_matches('/').into() + }, + ); + } + + let grant = policy.decide(&with_peer(request("/origin/edge0"), None)).await.unwrap(); + assert_eq!(grant.publish, patterns(&[""])); + let err = policy.decide(&with_peer(request("/anon"), None)).await.unwrap_err(); + assert!(matches!(err, Refusal::NoAccess { .. }), "{err}"); } #[tokio::test] @@ -686,9 +898,11 @@ mod tests { token: None, remote: Some(2), }, - revalidate: Duration::from_secs(60), + revalidate: Some(Duration::from_secs(60)), + expires: Some(Duration::from_secs(24 * 60 * 60)), ..Default::default() - }); + }) + .unwrap(); let first = request("/"); let second = request("/"); @@ -728,6 +942,49 @@ mod tests { assert_eq!(server.answer(&request("/")).await.unwrap_err(), Refusal::RemoteLimit); } + /// The re-check a session table ages out by, which every limit needs. + fn limited() -> Policy { + Policy { + revalidate: Some(Duration::from_secs(60)), + expires: Some(Duration::from_secs(3600)), + ..Default::default() + } + } + + /// Without a re-check, a relay that died would hold its slots forever, and a + /// re-check without a bound would outlive an outage. + #[test] + fn a_server_refuses_limits_without_a_cadence() { + let capped = Policy { + limits: Limits { + token: Some(1), + remote: None, + }, + ..Default::default() + }; + assert!(matches!(Server::new(capped), Err(Error::LimitsWithoutRevalidate))); + let unbounded = Policy { + revalidate: Some(Duration::from_secs(60)), + ..Default::default() + }; + assert!(matches!(Server::new(unbounded), Err(Error::UnboundedRevalidate))); + } + + /// With no limit to count against, nothing is kept per session, so a relay that + /// dies without sending `end` leaks nothing here. + #[tokio::test] + async fn no_limits_keep_no_sessions() { + let server = Server::new(Policy { + public: rules(&["**"], &["**"]), + ..Default::default() + }) + .unwrap(); + for _ in 0..3 { + server.answer(&request("/")).await.unwrap(); + } + assert!(server.sessions.lock().unwrap().slots.is_empty()); + } + #[tokio::test] async fn limits_count_sessions_per_token_and_fold_mapped_addresses() { let (dir, key) = key_dir(); @@ -737,8 +994,9 @@ mod tests { token: Some(1), remote: Some(1), }, - ..Default::default() - }); + ..limited() + }) + .unwrap(); let jwt = sign(&key, "demo", &["**"], &[], None); server.answer(&with_token(request("/demo"), &jwt)).await.unwrap(); @@ -766,7 +1024,7 @@ mod tests { token: None, remote: Some(1), }, - ..Default::default() + ..limited() }) .await; @@ -791,7 +1049,8 @@ mod tests { let server = Server::new(Policy { public: rules(&["**"], &["**"]), ..Default::default() - }); + }) + .unwrap(); tokio::spawn(async move { server.serve_unix(listener).await }); let url = url::Url::from_file_path(&path).unwrap(); diff --git a/rs/moq-auth/src/set.rs b/rs/moq-auth/src/set.rs index ddb5b8658d..75bb7700b9 100644 --- a/rs/moq-auth/src/set.rs +++ b/rs/moq-auth/src/set.rs @@ -147,6 +147,7 @@ mod tests { subscribe: patterns(&["test-sub/**"]), expires: Some(SystemTime::now() + Duration::from_secs(3600)), issued: Some(SystemTime::now()), + not_before: None, } } diff --git a/rs/moq-auth/src/wire.rs b/rs/moq-auth/src/wire.rs index c7fedc14ef..09d56739d5 100644 --- a/rs/moq-auth/src/wire.rs +++ b/rs/moq-auth/src/wire.rs @@ -31,6 +31,17 @@ pub(crate) struct Claims { exp: Option, #[serde_as(as = "Option>")] iat: Option, + #[serde_as(as = "Option>")] + nbf: Option, + // Registered claims that narrow nothing: an issuer's bookkeeping, read and dropped. + // Every other claim is refused, since an unknown one may narrow the grant, and a + // misspelled `root` would otherwise widen it to everything. + #[serde(skip_serializing)] + iss: Option, + #[serde(skip_serializing)] + sub: Option, + #[serde(skip_serializing)] + jti: Option, } impl From for Claims { @@ -44,6 +55,10 @@ impl From for Claims { subscribe: grants.subscribe, exp: claims.expires, iat: claims.issued, + nbf: claims.not_before, + iss: None, + sub: None, + jti: None, } } } @@ -65,6 +80,7 @@ impl TryFrom for crate::Claims { subscribe, expires: wire.exp, issued: wire.iat, + not_before: wire.nbf, }) } } diff --git a/rs/moq-auth/tests/released_token.rs b/rs/moq-auth/tests/released_token.rs new file mode 100644 index 0000000000..088976a2b8 --- /dev/null +++ b/rs/moq-auth/tests/released_token.rs @@ -0,0 +1,47 @@ +//! Tokens we sign against the verifier moq-relay 0.14 shipped (`moq-token` 0.7). +//! +//! A grant of subtrees is written as legacy `put`/`get` prefixes, which 0.14 reads +//! with the same scope. Anything a prefix can't say is written as +//! `publish`/`subscribe`, which 0.14 reads as granting nothing and refuses, rather +//! than misreading an exact grant as a prefix. + +use moq_auth::{Algorithm, Claims, Key}; + +fn patterns(texts: &[&str]) -> Vec { + texts.iter().map(|text| text.parse().unwrap()).collect() +} + +/// A key pair both crates hold: ours signs, the released one verifies. +fn keys() -> (Key, moq_token::Key) { + let key = Key::generate(Algorithm::HS256, None).unwrap(); + let released = moq_token::Key::from_str(&key.to_str().unwrap()).unwrap(); + (key, released) +} + +#[test] +fn subtree_grants_verify_on_0_14_with_the_same_scope() { + let (key, released) = keys(); + let claims = Claims::default() + .with_root("room") + .with_publish(patterns(&["alice/**"])) + .with_subscribe(patterns(&["**"])); + let verified = released.verify(&key.sign(&claims).unwrap()).unwrap(); + assert_eq!(verified.root, "room"); + assert_eq!(verified.publish, ["alice"]); + assert_eq!(verified.subscribe, [""]); +} + +#[test] +fn exact_grants_are_refused_by_0_14() { + let (key, released) = keys(); + for claims in [ + Claims::default().with_root("room").with_publish(patterns(&["alice"])), + Claims::default().with_subscribe(patterns(&["*/chat"])), + // One exact grant moves the whole document to patterns. + Claims::default() + .with_publish(patterns(&["alice/**"])) + .with_subscribe(patterns(&["bob"])), + ] { + assert!(released.verify(&key.sign(&claims).unwrap()).is_err(), "{claims:?}"); + } +} diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index 74f69c0200..23d61fa49e 100644 --- a/rs/moq-binary/CHANGELOG.md +++ b/rs/moq-binary/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.6...moq-binary-v0.1.7) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.5...moq-binary-v0.1.6) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.4...moq-binary-v0.1.5) - 2026-09-26 ### Other diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index f4b4ea44fb..439d2ea6eb 100644 --- a/rs/moq-binary/Cargo.toml +++ b/rs/moq-binary/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-binary/src/snapshot/mod.rs b/rs/moq-binary/src/snapshot/mod.rs index 4c5f005a38..f24ec05165 100644 --- a/rs/moq-binary/src/snapshot/mod.rs +++ b/rs/moq-binary/src/snapshot/mod.rs @@ -111,6 +111,31 @@ mod test { assert!(matches!(consumer.poll_next(&waiter), Poll::Ready(Ok(Some(v))) if v == "two")); } + /// A stamped payload is written at its capture time, and the returned size is the encoded frame + /// on the wire rather than the payload handed in. + #[test] + fn a_stamped_update_writes_its_capture_time() { + let (mut producer, track) = producer(true); + let mut groups = producer.consume(); + let captured = moq_net::Timestamp::from_millis(1_234).unwrap(); + let payload = Bytes::from(vec![7u8; 4096]); + let size = producer + .update(moq_net::Timed::from(payload.clone()).at(captured)) + .unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(size, frame.payload.len()); + assert!(size < payload.len(), "the size is the compressed frame"); + assert_eq!(drain(consume(track, true)), vec![payload]); + } + #[test] fn one_group_per_update() { let (mut producer, track) = producer(false); diff --git a/rs/moq-binary/src/snapshot/producer.rs b/rs/moq-binary/src/snapshot/producer.rs index 1ba32935b6..33d757ea51 100644 --- a/rs/moq-binary/src/snapshot/producer.rs +++ b/rs/moq-binary/src/snapshot/producer.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use bytes::Bytes; +use moq_net::Timed; use crate::Result; @@ -44,12 +45,12 @@ impl Producer { self.inner.lock().unwrap().track.is_used() } - /// Publish a new value, superseding the previous one. + /// Publish a new value, superseding the previous one, and return the frame's encoded size. /// /// Unlike [`moq-json`](https://docs.rs/moq-json), an identical value is republished rather than /// skipped: comparing two opaque blobs costs a full scan, and only the caller knows whether its /// bytes changed. - pub fn update(&mut self, payload: impl Into) -> Result<()> { + pub fn update(&mut self, payload: impl Into>) -> Result { self.inner.lock().unwrap().update(payload.into()) } @@ -66,7 +67,10 @@ struct Inner { } impl Inner { - fn update(&mut self, payload: Bytes) -> Result<()> { + fn update(&mut self, payload: Timed) -> Result { + let timestamp = payload.at.unwrap_or_else(moq_net::Timestamp::now); + let payload = payload.value; + // One frame per group, so the window spans a single value and starts cold every time. let payload = match self.compression { true => { @@ -88,8 +92,9 @@ impl Inner { return Err(moq_net::Error::FrameTooLarge.into()); } + let size = payload.len(); let mut group = self.track.append_group()?; - if let Err(err) = group.write_frame(moq_net::Timestamp::now(), payload) { + if let Err(err) = group.write_frame(timestamp, payload) { // `append_group` already published this group, and a rejected frame (too large) doesn't // close the track. Dropping the handle does NOT close the group, so leaving it would strand // any subscriber that advanced into it with nothing to read and no end. @@ -98,7 +103,7 @@ impl Inner { } group.finish()?; - Ok(()) + Ok(size) } fn finish(&mut self) -> Result<()> { diff --git a/rs/moq-binary/src/stream/mod.rs b/rs/moq-binary/src/stream/mod.rs index 7fa08f53a9..aa1699fbde 100644 --- a/rs/moq-binary/src/stream/mod.rs +++ b/rs/moq-binary/src/stream/mod.rs @@ -96,6 +96,32 @@ mod test { assert_eq!(drain(track, false), expected); } + /// Each record keeps its own capture time, and a bare payload is stamped when written. + #[test] + fn a_stamped_append_writes_its_capture_time() { + let (mut producer, _track) = producer(true); + let mut groups = producer.consume(); + let captured = moq_net::Timestamp::from_millis(1_234).unwrap(); + let first = producer + .append(moq_net::Timed::from(vec![1u8; 4096]).at(captured)) + .unwrap(); + producer.append(vec![2u8; 16]).unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(first, frame.payload.len(), "the size is the compressed frame"); + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_ne!(frame.timestamp.as_micros(), captured.as_micros()); + } + #[test] fn compressed_roundtrip_in_order() { let (mut producer, track) = producer(true); diff --git a/rs/moq-binary/src/stream/producer.rs b/rs/moq-binary/src/stream/producer.rs index 165b63bc9e..d24e04ba32 100644 --- a/rs/moq-binary/src/stream/producer.rs +++ b/rs/moq-binary/src/stream/producer.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use bytes::Bytes; +use moq_net::Timed; use crate::Result; @@ -52,7 +53,9 @@ impl Producer { /// log this mode promises, so the failure is surfaced rather than papered over with a second /// group. The group is aborted rather than closed cleanly, so a consumer sees the failure /// instead of a log that merely looks complete. Every later append fails on the closed track. - pub fn append(&mut self, payload: impl Into) -> Result<()> { + /// + /// Returns the frame's encoded size. + pub fn append(&mut self, payload: impl Into>) -> Result { self.inner.lock().unwrap().append(payload.into()) } @@ -74,7 +77,10 @@ struct Inner { } impl Inner { - fn append(&mut self, payload: Bytes) -> Result<()> { + fn append(&mut self, payload: Timed) -> Result { + let timestamp = payload.at.unwrap_or_else(moq_net::Timestamp::now); + let payload = payload.value; + // A payload no consumer could decode is as terminal as one the track rejects: the log is // missing a record either way, and carrying on would present that gap as a complete log. // Checked before the group is opened, so nothing is published, and routed through the same @@ -95,9 +101,10 @@ impl Inner { None => payload, }; + let size = payload.len(); let group = self.group.as_mut().expect("a group is open"); - let Err(err) = group.write_frame(moq_net::Timestamp::now(), payload) else { - return Ok(()); + let Err(err) = group.write_frame(timestamp, payload) else { + return Ok(size); }; // The payload never reached the wire, so the log has a hole in it, which is not the lossless diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index bedc2bcc32..5aed50492d 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.8](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.7...moq-boy-v0.5.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, moq-json, hang, moq-mux, moq-tokio, moq-video, moq-audio + +## [0.5.7](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.6...moq-boy-v0.5.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.5.6](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.5...moq-boy-v0.5.6) - 2026-09-26 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index d2407abfc2..71be7defbf 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.5.6" +version = "0.5.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-c/CHANGELOG.md b/rs/moq-c/CHANGELOG.md index 729971df95..e633377dfc 100644 --- a/rs/moq-c/CHANGELOG.md +++ b/rs/moq-c/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.8](https://github.com/moq-dev/moq/compare/libmoq-v0.6.7...libmoq-v0.6.8) - 2026-09-27 + +### Other + +- video resumes on a keyframe after discontinuity() ([#4285](https://github.com/moq-dev/moq/pull/4285)) + +## [0.6.7](https://github.com/moq-dev/moq/compare/libmoq-v0.6.6...libmoq-v0.6.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) + +### Fixed + +- *(libmoq)* write moq.h and moq.pc into OUT_DIR only ([#4243](https://github.com/moq-dev/moq/pull/4243)) + ## [0.6.6](https://github.com/moq-dev/moq/compare/libmoq-v0.6.5...libmoq-v0.6.6) - 2026-09-26 ### Other diff --git a/rs/moq-c/CMakeLists.txt b/rs/moq-c/CMakeLists.txt index 6f604d7af8..c1dbb4b158 100644 --- a/rs/moq-c/CMakeLists.txt +++ b/rs/moq-c/CMakeLists.txt @@ -71,8 +71,8 @@ target_include_directories(moq INTERFACE ${RUST_INCLUDE_DIR}) target_link_libraries(moq INTERFACE "${RUST_LIB}") # System libraries Rust std/deps pull in. When the staticlib is linked into a -# C/C++ target by an external linker, cargo can't inject these itself. build.rs -# reads the same files for moq-c.pc, so the two never drift. +# C/C++ target by an external linker, cargo can't inject these itself. +# nix/overlay.nix reads the same files for moq-c.pc, so the two never drift. # # Reads native-libs/.txt into MOQ_NATIVE_LIBS: `framework:Foo` becomes # a linker framework flag, anything else a plain library name. diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index adc0c26f9b..5d70b64d35 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -12,13 +12,12 @@ This will: - Build the static library (`libmoq.a` on Unix-like systems, `moq.lib` on Windows) - Generate the C header file at `$OUT_DIR/include/moq.h` -- Generate the pkg-config file at `$OUT_DIR/lib/pkgconfig/moq-c.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 moq-c. -`moq-c.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. +The pkg-config file (`moq-c.pc.in`) is rendered only when packaging (`nix build .#moq-c` +and the release tarballs), since its paths assume `lib/libmoq.a` beside `lib/pkgconfig/`. There's also a [CMakeLists.txt](CMakeLists.txt) file that can be used to import/build the library. diff --git a/rs/moq-c/build.rs b/rs/moq-c/build.rs index 52dfbb43ce..2208cf9761 100644 --- a/rs/moq-c/build.rs +++ b/rs/moq-c/build.rs @@ -21,11 +21,12 @@ const ENUMS: &[&str] = &[ fn main() { let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - let version = env::var("CARGO_PKG_VERSION").unwrap(); - // 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). + // The header lands in OUT_DIR: 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). moq-c.pc is not written here: its libdir has to name + // the directory holding libmoq.a, which only exists once packaged (see + // nix/overlay.nix), since cargo puts the staticlib outside OUT_DIR. let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // The `rerun-if-changed` below opts out of cargo's default "rerun when any @@ -42,6 +43,10 @@ fn main() { // header has no include guard unless we ask for one here. Without it a // project reaching moq.h down two include paths gets redefinition errors. pragma_once: true, + // C++ has to see these declarations with C linkage. Emitting the `extern "C"` + // block here saves every C++ consumer from wrapping the include by hand, which + // also wraps the system headers moq.h pulls in. + cpp_compat: true, export: cbindgen::ExportConfig { // These enums cross the ABI as plain `uint32_t`, so that an unknown // discriminant from C is an error rather than UB. That leaves no signature @@ -59,48 +64,4 @@ fn main() { .generate() .expect("Unable to generate bindings") .write_to_file(&header); - - let pc_in = PathBuf::from(&crate_dir).join("moq-c.pc.in"); - 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("moq-c.pc"); - if let Ok(template) = fs::read_to_string(&pc_in) { - let target = env::var("TARGET").unwrap(); - let libs_private = native_libs(&crate_dir, &target); - - let content = template - .replace("@VERSION@", &version) - .replace("@LIBS_PRIVATE@", &libs_private); - fs::write(&pc_out, content).expect("Failed to write pkg-config file"); - } -} - -/// Read the platform's `native-libs/` list and format it for pkg-config `Libs.private`. -/// -/// CMakeLists.txt reads the same files, so the two stay in sync by construction. -fn native_libs(crate_dir: &str, target: &str) -> String { - let platform = if target.contains("apple") { - "apple" - } else if target.contains("windows") { - "windows" - } else { - "linux" - }; - - let path = PathBuf::from(crate_dir) - .join("native-libs") - .join(format!("{}.txt", platform)); - println!("cargo:rerun-if-changed={}", path.display()); - - let list = fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e)); - - list.lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(|entry| match entry.strip_prefix("framework:") { - Some(framework) => format!("-framework {}", framework), - None => format!("-l{}", entry), - }) - .collect::>() - .join(" ") } diff --git a/rs/moq-c/cbindgen.toml b/rs/moq-c/cbindgen.toml index af1058b39b..63baf9e272 100644 --- a/rs/moq-c/cbindgen.toml +++ b/rs/moq-c/cbindgen.toml @@ -1,10 +1,11 @@ # cbindgen configuration for moq-c. # # NOT loaded: build.rs drives cbindgen through `Builder::new()`, which never -# discovers this file, and passes its own config. The generated moq.h therefore -# has none of the settings below (no include guard, no `#pragma once`, no -# renaming). Loading it now would rename every struct field and type in the -# published C API, so the options a build actually uses live in build.rs. +# discovers this file, and passes its own config. Of the settings below, only +# `pragma_once` and `cpp_compat` reach the generated moq.h, because build.rs sets +# them itself; the rest (the MOQ_H include guard, the renaming) do not. Loading +# it now would rename every struct field and type in the published C API, so +# the options a build actually uses live in build.rs. language = "C" include_guard = "MOQ_H" diff --git a/rs/moq-c/moq-c.pc.in b/rs/moq-c/moq-c.pc.in index 298baaf0c5..007efff425 100644 --- a/rs/moq-c/moq-c.pc.in +++ b/rs/moq-c/moq-c.pc.in @@ -1,6 +1,7 @@ # Paths are relative to this .pc file, in the install layout the release # tarballs and nix/overlay.nix ship: lib/{libmoq.a,pkgconfig/moq-c.pc} and -# include/moq.h under one prefix. +# include/moq.h under one prefix. nix/overlay.nix renders it there, and checks +# both paths resolve. libdir=${pcfiledir}/.. includedir=${pcfiledir}/../../include diff --git a/rs/moq-c/native-libs/apple.txt b/rs/moq-c/native-libs/apple.txt index 498703d637..02f4a5d8ee 100644 --- a/rs/moq-c/native-libs/apple.txt +++ b/rs/moq-c/native-libs/apple.txt @@ -2,7 +2,7 @@ # targets. Cargo injects these itself for Rust consumers, but a C/C++ toolchain # linking the staticlib has to spell them out. # -# Canonical list. build.rs bakes it into moq-c.pc, CMakeLists.txt reads it for the +# Canonical list. nix/overlay.nix bakes it into moq-c.pc, CMakeLists.txt reads it for the # moq::c target and the installed find_package config, and test/interop/interop.sh # links its C client with it. Keep the derived consumers reading this file rather # than repeating the list. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 02eef37ea7..7639b60b43 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -2256,7 +2256,8 @@ 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. +/// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock, +/// and video must resume on a keyframe. /// Returns zero on success, or a negative code on failure. #[unsafe(no_mangle)] pub extern "C" fn moq_publish_media_discontinuity(media: u32) -> i32 { diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 55370f5586..30b677f494 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.8](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.7...moq-cli-v0.12.8) - 2026-09-27 + +### Fixed + +- *(auth)* root public and mTLS rules at / ([#4318](https://github.com/moq-dev/moq/pull/4318)) +- *(cli)* close the relay connection on SIGINT and SIGTERM ([#4287](https://github.com/moq-dev/moq/pull/4287)) +- *(cli)* finish the catalog at stdin EOF ([#4303](https://github.com/moq-dev/moq/pull/4303)) + +## [0.12.7](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.6...moq-cli-v0.12.7) - 2026-09-26 + +### Fixed + +- *(cli)* keep delayed playback at the live edge ([#4241](https://github.com/moq-dev/moq/pull/4241)) + ## [0.12.6](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.5...moq-cli-v0.12.6) - 2026-09-26 ### Other diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index a9dc666f9d..bfec48b6f4 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.12.6" +version = "0.12.8" edition = "2024" # Depends on moq-relay, whose sysinfo 0.39 needs 1.95, above the 1.91 workspace # floor. This binary is an application, so the bump stays here rather than diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 4fc22ac33b..c333e82139 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -379,6 +379,7 @@ impl MoqSide { found.extend(self.quic.deprecated()); found.extend(self.server.deprecated()); found.extend(self.cluster.deprecated()); + found.extend(self.auth.deprecated()); if self.origin.is_some() { found.flag("--origin", Some("MOQ_ORIGIN"), "--hop / MOQ_HOP"); } @@ -465,6 +466,7 @@ impl MoqSide { } else if self.auth.url.is_some() || self.auth_public() { self.auth.validate()?; } + self.auth.validate_client_ca(!self.server.tls.root.is_empty())?; Ok(()) } @@ -1134,6 +1136,21 @@ mod tests { ); } + /// Public rules grant a certificate what they grant anyone, so a client CA on + /// a public listener refuses to start, as it does on the relay. + #[test] + fn a_client_ca_needs_an_auth_server() { + let parse = |auth: [&str; 2]| { + let mut argv = vec!["moq", "--listen-tcp-bind", "127.0.0.1:0", "--listen-tls-root", "ca.pem"]; + argv.extend(auth); + argv.extend(["import", "ts"]); + Invocation::try_parse_from(argv).expect("parse") + }; + let err = parse(["--auth-public", "**"]).moq.validate().unwrap_err().to_string(); + assert!(err.contains("--auth-public ignores"), "{err}"); + assert!(parse(["--auth-url", "http://127.0.0.1:4440/"]).moq.validate().is_ok()); + } + /// A listener for ordinary clients admits nobody without a decision, so it /// refuses to start with neither flag, and with both. #[test] diff --git a/rs/moq-cli/src/auth.rs b/rs/moq-cli/src/auth.rs index 4cbe6f806f..424314dd7f 100644 --- a/rs/moq-cli/src/auth.rs +++ b/rs/moq-cli/src/auth.rs @@ -371,19 +371,19 @@ pub struct Serve { #[usage(long, value_hint = usage::ValueHint::FilePath, extensions("json", "jwks"))] key_set: Option, - /// Patterns an anonymous session may publish (repeatable); `foo/**` for a subtree. + /// Patterns an anonymous session may publish, rooted at `/` (repeatable); `foo/**` for a subtree. #[usage(long)] public_publish: Vec, - /// Patterns an anonymous session may subscribe to (repeatable). + /// Patterns an anonymous session may subscribe to, rooted at `/` (repeatable). #[usage(long)] public_subscribe: Vec, - /// Patterns a session with a verified certificate may publish (repeatable). Empty refuses certificates. + /// Patterns a session with a verified certificate may publish, rooted at `/` (repeatable). Empty refuses certificates. #[usage(long)] mtls_publish: Vec, - /// Patterns a session with a verified certificate may subscribe to (repeatable). + /// Patterns a session with a verified certificate may subscribe to, rooted at `/` (repeatable). #[usage(long)] mtls_subscribe: Vec, @@ -391,19 +391,19 @@ pub struct Serve { #[usage(long)] tier: Option, - /// How often the relay re-checks each grant. - #[usage(long, default = "1m")] - revalidate: crate::duration::Duration, + /// How often the relay re-checks each grant. Off by default; needs `--expires`. + #[usage(long)] + revalidate: Option, - /// How long a grant with no bound of its own lasts: anonymous sessions, tokens without `exp`, certificates without one. - #[usage(long, default = "1d")] - expires: crate::duration::Duration, + /// How long a grant with no bound of its own lasts: anonymous sessions, tokens without `exp`, certificates without one. Off by default. + #[usage(long)] + expires: Option, - /// The most live sessions presenting one token. + /// The most live sessions presenting one token. Needs `--revalidate`. #[usage(long)] limit_token: Option, - /// The most live sessions from one remote address. + /// The most live sessions from one remote address. Needs `--revalidate`. #[usage(long)] limit_remote: Option, } @@ -411,10 +411,40 @@ pub struct Serve { impl Serve { /// The policy these flags describe, refusing a cadence the relay would spin on. fn policy(&self) -> anyhow::Result { - let revalidate = self.revalidate.into_std(); - if revalidate.is_zero() { + let revalidate = self.revalidate.map(|cadence| cadence.into_std()); + let expires = self.expires.map(|bound| bound.into_std()); + if revalidate.is_some_and(|cadence| cadence.is_zero()) { anyhow::bail!("--revalidate must be longer than 0s; every client would re-check in a tight loop"); } + if revalidate.is_some() && expires.is_none() { + anyhow::bail!("--revalidate needs --expires, so a grant re-checked through an outage still has a bound"); + } + // Only a re-check tells the session table a slot is still live; without one, + // a relay that died without an `end` would hold its slots forever. + if (self.limit_token.is_some() || self.limit_remote.is_some()) && revalidate.is_none() { + anyhow::bail!( + "--limit-token and --limit-remote need --revalidate, which ages out the slots of dead relays" + ); + } + // 0.14 read `anon` as the prefix `anon/`, and a pattern reads it as exactly the + // broadcast `anon`, so either silent reading would mislead someone upgrading. + let flags = [ + ("--public-publish", &self.public_publish), + ("--public-subscribe", &self.public_subscribe), + ("--mtls-publish", &self.mtls_publish), + ("--mtls-subscribe", &self.mtls_subscribe), + ]; + for (flag, patterns) in flags { + for pattern in patterns.iter().filter(|pattern| pattern.is_literal()) { + // A literal at the maximum depth is already its own subtree. + let subtree = Pattern::subtree(pattern.as_str())?; + if subtree != *pattern { + anyhow::bail!( + "{flag} `{pattern}` has no wildcard, so it names exactly one broadcast; write `{subtree}` for the subtree" + ); + } + } + } let rules = |publish: &[Pattern], subscribe: &[Pattern]| { Permissions::new(publish.iter().cloned().collect(), subscribe.iter().cloned().collect()) }; @@ -432,7 +462,7 @@ impl Serve { policy.mtls = rules(&self.mtls_publish, &self.mtls_subscribe); policy.tier = self.tier.clone(); policy.revalidate = revalidate; - policy.expires = self.expires.into_std(); + policy.expires = expires; policy.limits.token = self.limit_token; policy.limits.remote = self.limit_remote; Ok(policy) @@ -457,7 +487,7 @@ impl Serve { async fn run(self) -> anyhow::Result<()> { let listen = self.listener()?; - let server = moq_auth::serve::Server::new(self.policy()?); + let server = moq_auth::serve::Server::new(self.policy()?)?; match listen { Listen::Tcp(addr) => { let listener = tokio::net::TcpListener::bind(addr) @@ -644,8 +674,8 @@ mod tests { assert!(policy.public.publish.is_empty()); assert_eq!(policy.mtls.publish, ["**".parse().unwrap()].into_iter().collect()); assert_eq!(policy.tier.as_deref(), Some("internal")); - assert_eq!(policy.revalidate, std::time::Duration::from_secs(30)); - assert_eq!(policy.expires, std::time::Duration::from_secs(7200)); + assert_eq!(policy.revalidate, Some(std::time::Duration::from_secs(30))); + assert_eq!(policy.expires, Some(std::time::Duration::from_secs(7200))); assert_eq!(policy.limits.token, Some(3)); assert_eq!(policy.limits.remote, Some(8)); @@ -659,18 +689,66 @@ mod tests { .is_err() ); - // Nothing configured is a server that refuses everyone, on the defaults. + // Nothing configured is a server that refuses everyone, and like 0.14, never + // re-checks or closes a session on its own. let bare = serve(&["moq", "auth", "serve"]).policy().unwrap(); assert!(bare.keys.is_none()); assert!(bare.public.is_empty() && bare.mtls.is_empty()); - assert_eq!(bare.revalidate, std::time::Duration::from_secs(60)); - assert_eq!(bare.expires, std::time::Duration::from_secs(86400)); + assert_eq!(bare.revalidate, None); + assert_eq!(bare.expires, None); + + for (args, needs) in [ + // A zero cadence would have every client re-check in a tight loop. + (&["--revalidate", "0s", "--expires", "1h"][..], "longer than 0s"), + // The contract refuses a cadence without a bound. + (&["--revalidate", "1m"], "--revalidate needs --expires"), + // Only a re-check keeps a slot alive. + (&["--limit-token", "3"], "need --revalidate"), + (&["--limit-remote", "3", "--expires", "1h"], "need --revalidate"), + ] { + let argv: Vec<&str> = ["moq", "auth", "serve"].iter().chain(args).copied().collect(); + let err = serve(&argv).policy().unwrap_err().to_string(); + assert!(err.contains(needs), "{args:?}: {err}"); + } + let expiring = serve(&["moq", "auth", "serve", "--expires", "1h"]).policy().unwrap(); + assert_eq!(expiring.revalidate, None); + } - // A zero cadence would have every client re-check in a tight loop. - let err = serve(&["moq", "auth", "serve", "--revalidate", "0s"]) + /// 0.14 read `anon` as a prefix and a pattern reads it as one broadcast, so a + /// wildcard-free rule refuses to start rather than pick silently. + #[test] + fn serve_refuses_a_rule_without_a_wildcard() { + for (flag, rule, hint) in [ + ("--public-publish", "anon", "anon/**"), + ("--public-subscribe", "event/cam1.hang", "event/cam1.hang/**"), + ("--mtls-publish", "", "**"), + ("--mtls-subscribe", "origin", "origin/**"), + ] { + let err = serve(&["moq", "auth", "serve", flag, rule]).policy().unwrap_err(); + let err = err.to_string(); + assert!(err.contains(flag) && err.contains(&format!("`{hint}`")), "{err}"); + } + assert!( + serve(&[ + "moq", + "auth", + "serve", + "--public-subscribe", + "*/chat", + "--mtls-publish", + "origin/*" + ]) .policy() - .unwrap_err(); - assert!(err.to_string().contains("--revalidate"), "{err}"); + .is_ok() + ); + + // Nothing sits beneath a literal at the maximum depth, so it is its own subtree. + let deepest = vec!["a"; Pattern::MAX_SEGMENTS].join("/"); + assert!( + serve(&["moq", "auth", "serve", "--public-subscribe", &deepest]) + .policy() + .is_ok() + ); } #[cfg(unix)] diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index fa43d4587f..00f455767b 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -406,12 +406,12 @@ impl Directions { async fn spawn_moq( moq: &MoqSide, net: &Net, + client: moq_tokio::Client, cluster: moq_relay::cluster::Cluster, directions: Directions, tasks: &mut JoinSet>, ) -> anyhow::Result<(moq_net::bandwidth::Allocator, moq_net::origin::Producer)> { let mut bandwidth = moq_net::bandwidth::Allocator::unlimited(); - let client = net.client(moq.client.clone())?; let cluster = cluster .with_client(client.clone()) .with_client_tls(moq.client.tls.build()?) @@ -475,7 +475,8 @@ async fn run_play(moq: MoqSide, args: play::Args, net: Net) -> anyhow::Result<() consume: true, ..Default::default() }; - let (_, origin) = spawn_moq(&moq, &net, cluster, directions, &mut tasks).await?; + let client = net.client(moq.client.clone())?; + let (_, origin) = spawn_moq(&moq, &net, client, cluster, directions, &mut tasks).await?; play::run(origin.consume(), name, args, tasks) } @@ -483,7 +484,7 @@ async fn run_play(moq: MoqSide, args: play::Args, net: Net) -> anyhow::Result<() /// Run every stage over one Origin and one MoQ attachment. /// /// Stages are independent: each names its own broadcast and owns its own endpoint, -/// and the first to finish (stdin EOF, Ctrl-C, or an error) ends the process. +/// and the first to finish (stdin EOF, SIGINT, SIGTERM, or an error) ends the process. async fn run_stages(moq: MoqSide, stages: Vec, net: Net) -> anyhow::Result<()> { let cluster = moq.cluster()?; let mut tasks: JoinSet> = JoinSet::new(); @@ -493,40 +494,50 @@ async fn run_stages(moq: MoqSide, stages: Vec, net: Net) -> anyhow::Res // The stage combinations were refused up front by `Invocation::validate`, before // anything bound a port or dialed out. - let (bandwidth, origin) = spawn_moq(&moq, &net, cluster, Directions::of(&stages), &mut tasks).await?; - - // stdin and stdout are one resource each, so two stages can't share them. - let mut stdin = None; - let mut stdout = None; - - for stage in stages { - let name = stage.broadcast(&moq); - match stage { - Command::Import(import) => { - if import.source.stdin_format().is_some() { - claim("stdin", &mut stdin, &name)?; - } - if let Some(publish) = spawn_import(&origin, import, name, bandwidth.clone(), &mut tasks)? { - locals.push(publish); + let client = net.client(moq.client.clone())?; + let result = async { + let (bandwidth, origin) = + spawn_moq(&moq, &net, client.clone(), cluster, Directions::of(&stages), &mut tasks).await?; + + // stdin and stdout are one resource each, so two stages can't share them. + let mut stdin = None; + let mut stdout = None; + + for stage in stages { + let name = stage.broadcast(&moq); + match stage { + Command::Import(import) => { + if import.source.stdin_format().is_some() { + claim("stdin", &mut stdin, &name)?; + } + if let Some(publish) = spawn_import(&origin, import, name, bandwidth.clone(), &mut tasks)? { + locals.push(publish); + } } - } - Command::Export(export) => { - if export.sink.is_stdout() { - claim("stdout", &mut stdout, &name)?; + Command::Export(export) => { + if export.sink.is_stdout() { + claim("stdout", &mut stdout, &name)?; + } + spawn_export(&origin, export, name, &mut tasks)?; } - spawn_export(&origin, export, name, &mut tasks)?; + other => unreachable!("`{}` is not a stage", other.name()), } - other => unreachable!("`{}` is not a stage", other.name()), } - } - if locals.is_empty() { - return drive(tasks).await; + if locals.is_empty() { + drive(tasks).await + } else { + let local = tokio::task::LocalSet::new(); + supervise(&local, locals.into_iter().map(Publish::run), &mut tasks); + local.run_until(drive(tasks)).await + } } + .await; - let local = tokio::task::LocalSet::new(); - supervise(&local, locals.into_iter().map(Publish::run), &mut tasks); - local.run_until(drive(tasks)).await + // The process exits next, even on a setup error, so the relay only hears we left + // if the close goes out now. + client.close().await; + result } /// Run the non-Send pipelines on `local`, reporting each into `tasks`. @@ -744,13 +755,10 @@ async fn run_stdout(consumer: moq_net::origin::Consumer, name: String, args: Sub Subscribe::new(source, catalog, args).run().await } -/// Run every endpoint until the first finishes (stdin EOF, Ctrl-C, or an error), -/// then drop the rest. +/// Run every endpoint until the first finishes (stdin EOF, SIGINT, SIGTERM, or an +/// error), then drop the rest. async fn drive(mut tasks: JoinSet>) -> anyhow::Result<()> { - tasks.spawn(async { - let _ = tokio::signal::ctrl_c().await; - Ok(()) - }); + tasks.spawn(shutdown_signal()); while let Some(res) = tasks.join_next().await { match res { @@ -764,6 +772,24 @@ async fn drive(mut tasks: JoinSet>) -> anyhow::Result<()> { Ok(()) } +/// Resolve on SIGINT or, on unix, SIGTERM (what process supervisors send on stop). +async fn shutdown_signal() -> anyhow::Result<()> { + #[cfg(unix)] + { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("failed to listen for SIGTERM")?; + tokio::select! { + res = tokio::signal::ctrl_c() => res.context("failed to listen for SIGINT")?, + _ = term.recv() => {} + } + Ok(()) + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await.context("failed to listen for SIGINT") + } +} + /// The listener / HTTP-serving endpoints bridge one named broadcast, so an /// empty `--broadcast` is rejected rather than silently defaulting to the root. fn require_broadcast(name: String, endpoint: &str) -> anyhow::Result { diff --git a/rs/moq-cli/src/play/buffer.rs b/rs/moq-cli/src/play/buffer.rs index 3c9e9599c0..9055bb2eb5 100644 --- a/rs/moq-cli/src/play/buffer.rs +++ b/rs/moq-cli/src/play/buffer.rs @@ -29,6 +29,12 @@ impl Buffer { self.waiting_keyframe = true; } + /// The earliest presentation time still buffered, wherever it sits in + /// decode order. + pub fn oldest(&self) -> Option { + self.oldest.front().copied() + } + pub fn pop(&mut self) -> Option { let frame = self.frames.pop_front()?; self.bytes -= frame.payload.len(); diff --git a/rs/moq-cli/src/play/video.rs b/rs/moq-cli/src/play/video.rs index 51a6efb441..9469c73fe5 100644 --- a/rs/moq-cli/src/play/video.rs +++ b/rs/moq-cli/src/play/video.rs @@ -1,10 +1,9 @@ //! Receive encoded video independently of the paced decoder and window. -use std::collections::VecDeque; +use std::collections::{BTreeSet, VecDeque}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use hang::moq_net; use moq_mux::container::Frame; use tokio::time::Instant; @@ -13,10 +12,36 @@ use super::output::Output; use super::timeline::Presentation; use super::window::Event; -/// A few surfaces, independent of the requested playout delay. +/// A few surfaces, independent of the requested playout delay. A codec batch +/// that overflows it waits beside the queue rather than pushing out pictures +/// the window has yet to show. pub(super) const MAX_FRAMES: usize = 3; +/// How long before the earliest owed picture is due to feed the codec. const DECODE_AHEAD: Duration = Duration::from_millis(100); +/// What the decode loop does next. +enum Step { + /// Sleep until the instant, or until the buffer or the window changes. + Wait(Option), + Decode, + /// The track ended: drain what the codec still holds. + Flush, +} + +/// When the window's queue has room for another picture, or `None` if it does +/// now. +/// +/// The window presents the newest due picture, so a full queue's oldest one +/// is skipped anyway once the next falls due. Making room then keeps a +/// stalled window from stalling decode without dropping a picture a live one +/// would show. +fn vacancy(queue: &VecDeque, presentation: &Presentation) -> Option { + if queue.len() < MAX_FRAMES { + return None; + } + queue.get(1).and_then(|frame| presentation.due(frame.timestamp)) +} + type Track = moq_mux::container::Consumer; /// The window's shared state and the subscription's encoded retention budget. @@ -89,106 +114,160 @@ impl Video { Ok::<_, anyhow::Error>(()) }; let decode = async { + // The generation the decoder's output belongs to, while it holds any. let mut generation = None; + // Presentation times fed to the decoder that it has not returned yet. + let mut held = BTreeSet::new(); + // Decoded pictures waiting for room in the window's queue. + let mut pending = VecDeque::new(); loop { - let next = { + let (current, oldest, ended) = { let buffer = buffer.lock().unwrap(); - buffer.frames.front().map(|frame| frame.timestamp) + (buffer.generation, buffer.oldest(), buffer.ended) }; - let Some(timestamp) = next else { - if buffer.lock().unwrap().ended { + if generation.is_some_and(|generation| generation != current) { + // Whatever the codec still holds sits below the new floor, so it is + // filtered on the way out and owes the window nothing. + generation = None; + held.clear(); + pending.clear(); + } + let step = { + let mut queue = self.frames.lock().unwrap(); + let presentation = self.presentation.lock().unwrap(); + let now = Instant::now().into_std(); + let mut placed = false; + while !pending.is_empty() && vacancy(&queue, &presentation).is_none_or(|at| at <= now) { + if queue.len() >= MAX_FRAMES { + queue.pop_front(); + } + let frame: moq_video::Frame = pending.pop_front().expect("checked by the loop"); + let index = queue.partition_point(|queued| queued.timestamp <= frame.timestamp); + queue.insert(index, frame); + placed = true; + } + if placed { + self.output.send(Event::Wake); + } + if !pending.is_empty() { + Step::Wait(vacancy(&queue, &presentation)) + } else if let Some(oldest) = oldest { + // Every access unit up to the earliest picture still owed must be + // decoded before that picture is due, however deep the stream + // reorders or the codec holds pictures back. + let owed = held.first().map_or(oldest, |held| oldest.min(*held)); + let at = presentation + .due(owed) + .and_then(|at| at.checked_sub(DECODE_AHEAD)) + .max(vacancy(&queue, &presentation)); + match at.filter(|at| *at > now) { + Some(at) => Step::Wait(Some(at)), + None => Step::Decode, + } + } else if !ended { + Step::Wait(None) + } else if generation.is_some() { + Step::Flush + } else { break; } - self.changed.notified().await; - continue; }; - let at = { - let frames = self.frames.lock().unwrap(); - let presentation = self.presentation.lock().unwrap(); - let mut at = presentation.due(timestamp).and_then(|at| at.checked_sub(DECODE_AHEAD)); - // A full window may lag, but a future picture still deserves its - // slot. Once due, evict it rather than blocking the live reader. - if frames.len() >= MAX_FRAMES { - at = at.max(frames.front().and_then(|frame| presentation.due(frame.timestamp))); + let frames = match step { + Step::Wait(Some(at)) => { + tokio::select! { + _ = tokio::time::sleep_until(at.into()) => {}, + _ = self.changed.notified() => {}, + } + continue; } - at - }; - if let Some(at) = at.filter(|at| *at > Instant::now().into_std()) { - tokio::select! { - _ = tokio::time::sleep_until(at.into()) => {}, - _ = self.changed.notified() => {}, + Step::Wait(None) => { + self.changed.notified().await; + continue; + } + Step::Decode => { + let frame = buffer + .lock() + .unwrap() + .pop() + .expect("no await since inspecting the buffer"); + generation = Some(current); + held.insert(frame.timestamp); + // Never race a codec call: cancelling Sink::decode poisons it. The + // joined receiver continues observing arrivals during this await. + decoder.decode(frame).await? + } + Step::Flush => { + generation = None; + held.clear(); + decoder.flush().await? } - continue; - } - let (frame, current) = { - let mut buffer = buffer.lock().unwrap(); - ( - buffer.pop().expect("no await since inspecting the front"), - buffer.generation, - ) }; - // Never race a codec call: cancelling Sink::decode poisons it. The - // joined receiver continues observing arrivals during this await. - let frames = decoder.decode(frame).await?; - generation = Some(current); - if buffer.lock().unwrap().generation == current { - self.decoded(frames, buffer.lock().unwrap().floor); + // A codec returns display order, so a picture held before the newest + // one it returned was dropped rather than delayed. + if let Some(last) = frames.iter().map(|frame| frame.timestamp).max() { + held.retain(|held| *held > last); } - } - let tail = decoder.flush().await?; - if generation == Some(buffer.lock().unwrap().generation) { - self.decoded(tail, buffer.lock().unwrap().floor); + // A codec may return pictures it held across the keyframe that + // resumed a skipped timeline. Those pictures no longer have a slot. + let floor = buffer.lock().unwrap().floor; + pending.extend( + frames + .into_iter() + .filter(|frame| floor.is_none_or(|floor| frame.timestamp.as_micros() >= floor.as_micros())), + ); } Ok::<_, anyhow::Error>(()) }; tokio::try_join!(receive, decode)?; Ok(()) } - - fn decoded(&self, frames: Vec, floor: Option) { - let mut queue = self.frames.lock().unwrap(); - for frame in frames { - // A codec may return pictures it held across the keyframe that - // resumed a skipped timeline. Those pictures no longer have a slot. - if floor.is_some_and(|floor| frame.timestamp.as_micros() < floor.as_micros()) { - continue; - } - let index = queue.partition_point(|queued| queued.timestamp <= frame.timestamp); - queue.insert(index, frame); - while queue.len() > MAX_FRAMES { - queue.pop_front(); - } - } - self.output.send(Event::Wake); - } } #[cfg(test)] mod tests { use super::super::{args::Args, fake::Recorder, media::Media}; use super::*; + use hang::moq_net; - #[derive(Default)] + /// A codec whose reorder buffer holds `depth` pictures and bumps the + /// earliest one out, the way a real decoder returns display order. struct Buffered { - pending: Option, + depth: usize, + held: Vec, decoded: Arc>>, flushed: Arc>, } + impl Default for Buffered { + fn default() -> Self { + Self::new(1) + } + } + + impl Buffered { + fn new(depth: usize) -> Self { + Self { + depth, + held: Vec::new(), + decoded: Default::default(), + flushed: Default::default(), + } + } + } + impl Decoder for Buffered { async fn decode(&mut self, frame: Frame) -> anyhow::Result> { self.decoded.lock().unwrap().push((frame.timestamp, Instant::now())); let surface = moq_video::Surface::rgba(&[128; 16 * 16 * 4], moq_video::Size::new(16, 16))?; - Ok(self - .pending - .replace(moq_video::Frame::new(surface, frame.timestamp)) - .into_iter() - .collect()) + self.held.push(moq_video::Frame::new(surface, frame.timestamp)); + self.held.sort_by_key(|frame| frame.timestamp); + let bumped = self.held.len().saturating_sub(self.depth); + Ok(self.held.drain(..bumped).collect()) } async fn flush(&mut self) -> anyhow::Result> { *self.flushed.lock().unwrap() += 1; - Ok(self.pending.take().into_iter().collect()) + Ok(std::mem::take(&mut self.held)) } } @@ -340,11 +419,7 @@ mod tests { tokio::time::advance(Duration::from_millis(1)).await; } task.await.unwrap().unwrap(); - assert_eq!( - shown, - [33, 66, 99], - "the bounded queue evicts the oldest, then presents reordered output" - ); + assert_eq!(shown, [0, 33, 66, 99], "reordered output was not presented in order"); } #[tokio::test] async fn a_discontinuity_discards_old_pictures_and_restarts_the_delay() { @@ -402,4 +477,61 @@ mod tests { assert_eq!(queued, [500], "old decoder output crossed the discontinuity"); assert_eq!(media.output.present(&media).unwrap().as_millis(), 500); } + + /// Play one burst of 33 ms pictures, given by slot in decode order, through a + /// codec holding `depth` pictures, and check the window shows every one on + /// time. + async fn schedule(order: &[u64], depth: usize) { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let media = media(delay); + let track = track(order.iter().map(|&slot| frame(slot * 33, slot == 0)), delay).await; + let task = tokio::spawn(playback(&media, delay).run(track, Buffered::new(depth))); + tokio::task::yield_now().await; + let last = moq_net::Timestamp::from_millis(order.iter().max().unwrap() * 33).unwrap(); + let end = media.presentation.lock().unwrap().due(last).unwrap(); + let mut shown = Vec::new(); + while Instant::now().into_std() <= end + Duration::from_millis(10) { + if let Some(timestamp) = media.output.present(&media) { + shown.push((timestamp, Instant::now().into_std())); + } + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + } + task.await.unwrap().unwrap(); + let mut expected = order.iter().map(|slot| (slot * 33) as u128).collect::>(); + expected.sort(); + assert_eq!( + shown + .iter() + .map(|(timestamp, _)| timestamp.as_millis()) + .collect::>(), + expected, + "a picture was dropped" + ); + let presentation = media.presentation.lock().unwrap(); + for (timestamp, at) in shown { + let due = presentation.due(timestamp).unwrap(); + assert!( + at.saturating_duration_since(due) <= Duration::from_millis(1), + "{timestamp:?} was shown {:?} late", + at - due + ); + } + } + + /// A hierarchical GOP: the reference coded second is presented 264 ms after + /// the first picture, so the B-pictures coded after it need it decoded far + /// more than 100 ms before its own deadline. + #[tokio::test] + async fn reordering_deeper_than_the_decode_lead_stays_on_time() { + schedule(&[0, 8, 4, 2, 1, 3, 6, 5, 7], 3).await; + } + + /// A codec holding more pictures than the window's queue returns a tail + /// larger than the queue when it is flushed. + #[tokio::test] + async fn a_flush_larger_than_the_queue_keeps_its_tail() { + schedule(&(0..10).collect::>(), 5).await; + } } diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 95722faa89..1dcd5607a7 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -222,12 +222,32 @@ impl PublishDecoder { } } +/// The catalog a stdin decoder publishes into. TS carries the `mpegts` extension. +enum PublishCatalog { + Media(moq_mux::catalog::Producer), + Ts(moq_mux::catalog::Producer), +} + +impl PublishCatalog { + /// End the catalog tracks cleanly, keeping the renditions they last listed. + fn finish(&mut self) -> anyhow::Result<()> { + match self { + Self::Media(catalog) => catalog.finish()?, + Self::Ts(catalog) => catalog.finish()?, + } + Ok(()) + } +} + // Exactly one Source exists per process, so the size gap between the small // Stream variant and the larger Capture config is irrelevant. #[allow(clippy::large_enum_variant)] enum Source { /// Decode a container read from stdin. - Stream(PublishDecoder), + Stream { + decoder: PublishDecoder, + catalog: PublishCatalog, + }, /// Capture from local devices. The per-medium producers are built on their /// own capture threads (native camera/screen capture, microphone via cpal), publishing /// onto the shared broadcast + catalog; [`Publish::run`] drives them @@ -272,34 +292,43 @@ impl Publish { let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; let ts = ts::Import::new(broadcast.clone(), catalog.reserve()).live(); return Ok(Self { - source: Source::Stream(PublishDecoder::Ts(Box::new(ts))), + source: Source::Stream { + decoder: PublishDecoder::Ts(Box::new(ts)), + catalog: PublishCatalog::Ts(catalog), + }, broadcast, }); } let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; - let source = match format { + let decoder = match format { PublishFormat::Avc3 => { let track = broadcast.unique_track(".avc3", catalog.track_info(hang::catalog::PRIORITY.video))?; let import = moq_mux::codec::h264::Import::new(track, catalog.reserve(), Default::default())?; let split = Box::new(moq_mux::codec::h264::Split::new()); - Source::Stream(PublishDecoder::Avc3 { + PublishDecoder::Avc3 { split, import: Box::new(import), - }) + } } PublishFormat::Fmp4 => { let fmp4 = fmp4::Import::new(broadcast.clone(), catalog.reserve()).live(); - Source::Stream(PublishDecoder::Fmp4(Box::new(fmp4))) + PublishDecoder::Fmp4(Box::new(fmp4)) } PublishFormat::Ts => unreachable!("TS is handled above with the mpegts catalog extension"), PublishFormat::Flv => { let flv = flv::Import::new(broadcast.clone(), catalog.reserve()).live(); - Source::Stream(PublishDecoder::Flv(Box::new(flv))) + PublishDecoder::Flv(Box::new(flv)) } }; - Ok(Self { source, broadcast }) + Ok(Self { + source: Source::Stream { + decoder, + catalog: PublishCatalog::Media(catalog), + }, + broadcast, + }) } /// Build a publisher capturing local devices (camera/screen and microphone). @@ -345,47 +374,7 @@ impl Publish { /// Drive the source until stdin EOF (or the capture devices stop). pub async fn run(self) -> anyhow::Result<()> { match self.source { - Source::Stream(mut decoder) => { - let mut stdin = tokio::io::stdin(); - let mut buffer = bytes::BytesMut::new(); - - // Damage reported so far, so only the change is logged. A live feed is - // diagnosed by the rate at which these climb, and stdin may never end, so - // they have to surface as they accumulate rather than at exit. - let mut reported = decoder.stats(); - - // Run the read/decode loop so an error surfaces here rather than - // dropping the decoder (and its tracks) with a bare Error::Dropped. - let result: anyhow::Result<()> = async { - loop { - buffer.clear(); - let n = tokio::io::AsyncReadExt::read_buf(&mut stdin, &mut buffer).await?; - if n == 0 { - return Ok(()); // EOF - } - decoder.decode_chunk(&buffer)?; - - let latest = decoder.stats(); - if latest != reported { - log_stats(latest.as_ref(), reported.as_ref()); - reported = latest; - } - } - } - .await; - - // Flush on a clean EOF; on any error (read, decode, or the flush - // itself) abort with the real cause so subscribers see it instead of - // a bare Error::Dropped. - let outcome = result.and_then(|()| decoder.finish()); - // The drain at end of input can publish a frame nothing vouched for, so the - // final snapshot is only complete after `finish`. - log_stats(decoder.stats().as_ref(), reported.as_ref()); - if let Err(err) = &outcome { - decoder.abort(moq_net::Error::Transport(err.to_string())); - } - outcome - } + Source::Stream { decoder, catalog } => decode(decoder, catalog, tokio::io::stdin()).await, #[cfg(feature = "capture")] Source::Capture { catalog, video, audio } => { // Each enabled medium publishes its own track onto the shared @@ -439,6 +428,59 @@ impl Publish { } } +/// Decode `input` into the broadcast until EOF. +/// +/// At EOF the media tracks finish, then the catalog does, while it still lists them: the +/// renditions retire from the catalog as the decoder drops, which a subscriber would read +/// as removed tracks, and a catalog dropped unfinished reads as a publisher that vanished. +async fn decode( + mut decoder: PublishDecoder, + mut catalog: PublishCatalog, + mut input: impl tokio::io::AsyncRead + Unpin, +) -> anyhow::Result<()> { + let mut buffer = bytes::BytesMut::new(); + + // Damage reported so far, so only the change is logged. A live feed is + // diagnosed by the rate at which these climb, and stdin may never end, so + // they have to surface as they accumulate rather than at exit. + let mut reported = decoder.stats(); + + // Run the read/decode loop so an error surfaces here rather than + // dropping the decoder (and its tracks) with a bare Error::Dropped. + let result: anyhow::Result<()> = async { + loop { + buffer.clear(); + let n = tokio::io::AsyncReadExt::read_buf(&mut input, &mut buffer).await?; + if n == 0 { + return Ok(()); // EOF + } + decoder.decode_chunk(&buffer)?; + + let latest = decoder.stats(); + if latest != reported { + log_stats(latest.as_ref(), reported.as_ref()); + reported = latest; + } + } + } + .await; + + // Flush on a clean EOF; on any error (read, decode, or the flush + // itself) abort with the real cause so subscribers see it instead of + // a bare Error::Dropped. + let outcome = result.and_then(|()| decoder.finish()); + // The drain at end of input can publish a frame nothing vouched for, so the + // final snapshot is only complete after `finish`. + log_stats(decoder.stats().as_ref(), reported.as_ref()); + match outcome { + Ok(()) => catalog.finish(), + Err(err) => { + decoder.abort(moq_net::Error::Transport(err.to_string())); + Err(err) + } + } +} + #[cfg(feature = "capture")] impl CaptureArgs { /// The video source named by the flags, defaulting to the default camera. @@ -694,7 +736,7 @@ mod tests { settle().await; let mut publish = Publish::new(broadcast, &PublishFormat::Ts, Default::default()).unwrap(); #[allow(irrefutable_let_patterns)] - let Source::Stream(decoder) = &mut publish.source else { + let Source::Stream { decoder, .. } = &mut publish.source else { panic!("expected a stream source"); }; decoder.decode_chunk(&input).unwrap(); @@ -775,7 +817,7 @@ mod tests { let config = moq_mux::catalog::Config::default().with_clock(clock); let mut publish = Publish::new(broadcast, &PublishFormat::Ts, config).unwrap(); #[allow(irrefutable_let_patterns)] - let Source::Stream(decoder) = &mut publish.source else { + let Source::Stream { decoder, .. } = &mut publish.source else { panic!("expected a stream source"); }; let before = clock.now(); @@ -812,6 +854,37 @@ mod tests { ); } + /// At stdin EOF the catalog ends cleanly and still lists the renditions, so a + /// subscriber reads a finished broadcast rather than its tracks being removed. + #[tokio::test(start_paused = true)] + async fn eof_finishes_the_catalog_with_its_renditions() { + let broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let publish = Publish::new(broadcast, &PublishFormat::Ts, Default::default()).unwrap(); + let mut catalogs = hang::catalog::Catalog::<()>::subscribe(&consumer).await.unwrap(); + + #[allow(irrefutable_let_patterns)] + let Source::Stream { decoder, catalog } = publish.source else { + panic!("expected a stream source"); + }; + decode(decoder, catalog, BBB).await.unwrap(); + drop(publish.broadcast); + + let mut last = None; + loop { + let next = tokio::time::timeout(Duration::from_secs(1), catalogs.next()) + .await + .expect("the catalog track ends"); + match next.expect("the catalog ends cleanly") { + Some(catalog) => last = Some(catalog), + None => break, + } + } + let last = last.expect("a catalog"); + assert_eq!(last.video.renditions.len(), 1, "the video rendition is still listed"); + assert_eq!(last.audio.renditions.len(), 1, "the audio rendition is still listed"); + } + /// Read the first frame of a verbatim track back as raw bytes. async fn read_frame(consumer: &moq_net::broadcast::Consumer, name: &str) -> Vec { let track = consumer.track(name).unwrap().subscribe(None).await.unwrap(); diff --git a/rs/moq-e2ee/CHANGELOG.md b/rs/moq-e2ee/CHANGELOG.md index 4172aa7f0b..e51500a8ed 100644 --- a/rs/moq-e2ee/CHANGELOG.md +++ b/rs/moq-e2ee/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.8](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.7...moq-e2ee-v0.0.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + +## [0.0.7](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.6...moq-e2ee-v0.0.7) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.0.6](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.5...moq-e2ee-v0.0.6) - 2026-09-26 ### Other diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index e87619e285..7ae4e28b5e 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.6" +version = "0.0.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index 9d52f99f9e..982b8aa471 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.7...moq-ffi-v0.4.8) - 2026-09-27 + +### Added + +- *(kt)* end a broadcast with end() ([#4259](https://github.com/moq-dev/moq/pull/4259)) + +### Other + +- fix stale agent rules, the moq-net hop range, and the ffi unannounce doc ([#4305](https://github.com/moq-dev/moq/pull/4305)) +- video resumes on a keyframe after discontinuity() ([#4285](https://github.com/moq-dev/moq/pull/4285)) + +## [0.4.7](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.6...moq-ffi-v0.4.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) + ## [0.4.6](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.5...moq-ffi-v0.4.6) - 2026-09-26 ### Other diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index b9e8f8c5ef..60d3bfd653 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.6" +version = "0.4.8" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 499cd6c8e2..c471339c04 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -256,8 +256,8 @@ impl MoqBroadcastProducer { /// Retract this broadcast's exact-path advertisement, if any. /// /// Local consumers and peers alike stop discovering and requesting it; - /// tracks already in flight carry on. Announcing again brings it back. Errors - /// with `Closed` on a standalone broadcast (no origin to announce on). + /// tracks already in flight carry on. Announcing again brings it back. A no-op + /// on a standalone broadcast. pub fn unannounce(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { @@ -980,7 +980,8 @@ impl MoqMediaProducer { /// Mark a timeline break and restart handoff measurement without lowering advertised jitter. /// - /// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock. + /// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock, + /// and video must resume on a keyframe. pub fn discontinuity(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 5461b5432c..f8d3f075ee 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.7...moq-gst-v0.4.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux, moq-tokio + +## [0.4.7](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.6...moq-gst-v0.4.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) + ## [0.4.6](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.5...moq-gst-v0.4.6) - 2026-09-26 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 40439c0c0b..7adbdccd52 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.6" +version = "0.4.8" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index c2bf6d1f2f..6fa5ba2c88 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -52,7 +52,9 @@ struct Media { /// 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. + /// opens the next one. Stays set once armed: a successful decode may publish nothing (a + /// header-only buffer), and a delta only misses its keyframe while no group is open, which for + /// video means after a break. keyframe: bool, } @@ -69,7 +71,6 @@ impl Media { 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 { @@ -1760,6 +1761,32 @@ mod tests { assert_eq!(push(&mut pad, delta, 166), PushOutcome::Published); } + // A header-only buffer after a break publishes no frame, so no group opens and the deltas that + // follow must still drop rather than invalidate the pad. + #[test] + fn video_header_only_buffer_keeps_waiting_for_the_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()); + // The SPS and PPS of the keyframe AU, without its IDR slice. + let keyframe = h264_keyframe_au(); + let headers = keyframe.slice(..keyframe.len() - 9); + 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, keyframe.clone(), 0), PushOutcome::Published); + pad.discontinuity(); + assert_eq!(push(&mut pad, headers, 33), PushOutcome::Published); + assert_eq!(push(&mut pad, delta.clone(), 66), PushOutcome::Dropped); + assert_eq!(push(&mut pad, keyframe, 100), PushOutcome::Published); + assert_eq!(push(&mut pad, delta, 133), 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-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index 967b0c349e..e8c4060921 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.8](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.7...moq-hls-v0.5.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + +## [0.5.7](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.6...moq-hls-v0.5.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + ## [0.5.6](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.5...moq-hls-v0.5.6) - 2026-09-26 ### Other diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 090727281d..47b2a92d4d 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.6" +version = "0.5.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index eb07288e02..3fe1b6d5a7 100644 --- a/rs/moq-json/CHANGELOG.md +++ b/rs/moq-json/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.5](https://github.com/moq-dev/moq/compare/moq-json-v0.5.4...moq-json-v0.5.5) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + +## [0.5.4](https://github.com/moq-dev/moq/compare/moq-json-v0.5.3...moq-json-v0.5.4) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.5.3](https://github.com/moq-dev/moq/compare/moq-json-v0.5.2...moq-json-v0.5.3) - 2026-09-26 ### Other diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 62944fcdb3..62dc702b48 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.3" +version = "0.5.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/src/snapshot/mod.rs b/rs/moq-json/src/snapshot/mod.rs index c8cdabf2b9..9b86d81e8d 100644 --- a/rs/moq-json/src/snapshot/mod.rs +++ b/rs/moq-json/src/snapshot/mod.rs @@ -307,14 +307,44 @@ mod test { #[test] fn unchanged_value_writes_nothing() { let (mut producer, track) = producer(Config::default()); - producer.update(&json!({ "a": 1 })).unwrap(); - producer.update(&json!({ "a": 1 })).unwrap(); + assert_eq!(producer.update(&json!({ "a": 1 })).unwrap(), Some(7)); + assert_eq!( + producer.update(&json!({ "a": 1 })).unwrap(), + None, + "an unchanged value reports that nothing was written" + ); producer.finish().unwrap(); assert_eq!(track.latest(), Some(0)); assert_eq!(drain(track), vec![json!({ "a": 1 })]); } + /// A stamped value is written at its capture time, snapshot and delta alike, and the returned + /// size is the encoded frame. + #[test] + fn a_stamped_update_writes_its_capture_time() { + let (mut producer, _track) = producer(cfg(100)); + let mut groups = producer.consume(); + let first = moq_net::Timestamp::from_millis(1_000).unwrap(); + let second = moq_net::Timestamp::from_millis(2_000).unwrap(); + let value = json!({ "a": 1, "b": "x".repeat(64) }); + let size = producer.update(moq_net::Timed::from(&value).at(first)).unwrap(); + let changed = json!({ "a": 2, "b": "x".repeat(64) }); + let delta = producer.update(moq_net::Timed::from(&changed).at(second)).unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + for (stamp, size) in [(first, size), (second, delta)] { + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), stamp.as_micros()); + assert_eq!(size, Some(frame.payload.len())); + } + } + #[test] fn deltas_share_one_group() { let (mut producer, track) = producer(cfg(100)); @@ -569,11 +599,16 @@ mod test { *producer.modify().unwrap() = json!({ "big": "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize + 1) }); - // The publisher learns the cause at its next edit, the consumer from the aborted track. + // The publisher learns the cause at its next edit. The consumer drains the snapshot + // that finished, then learns it from the aborted track. assert!(matches!( producer.modify(), Err(crate::Error::Net(moq_net::Error::FrameTooLarge)) )); + assert!(matches!( + subscriber.poll_next_group(&kio::Waiter::noop()), + Poll::Ready(Ok(Some(_))) + )); assert!(matches!( subscriber.poll_next_group(&kio::Waiter::noop()), Poll::Ready(Err(moq_net::Error::FrameTooLarge)) diff --git a/rs/moq-json/src/snapshot/producer.rs b/rs/moq-json/src/snapshot/producer.rs index fea24c61df..da62df41a4 100644 --- a/rs/moq-json/src/snapshot/producer.rs +++ b/rs/moq-json/src/snapshot/producer.rs @@ -8,6 +8,8 @@ use serde::Serialize; use serde::de::DeserializeOwned; use super::{Encoded, Encoder}; +use moq_net::Timed; + use crate::{Error, Result}; pub use super::Config; @@ -85,9 +87,13 @@ impl Producer { /// Publish a new value, emitting a snapshot or a delta automatically. /// - /// Does nothing if the value is unchanged from the previous publish. - pub fn update(&mut self, value: &T) -> Result<()> { - take(&self.inner).update(value) + /// Returns the encoded size of the frame written, or `None` if the value is unchanged from the + /// previous publish and nothing was written. + pub fn update<'a>(&mut self, value: impl Into>) -> Result> + where + T: 'a, + { + take(&self.inner).update(value.into()) } /// Edit the current value in place and publish the result. @@ -226,7 +232,8 @@ impl Guard<'_, T> { self.dirty = false; // We already hold the lock, so publish through the held guard rather than re-locking. - self.inner.update(&self.value) + self.inner.update(Timed::from(&self.value))?; + Ok(()) } } @@ -318,21 +325,23 @@ impl Inner { } impl Inner { - fn update(&mut self, value: &T) -> Result<()> { + fn update(&mut self, payload: Timed<&T>) -> Result> { // Split the borrow so `frame` can hold the encoder while `track` is written through. let Inner { track, encoder, .. } = self; - let Some(frame) = encoder.update(value)? else { - return Ok(()); + let Some(frame) = encoder.update(payload.value)? else { + return Ok(None); }; // A failed write drops `frame` uncommitted, which resets the encoder so the next update // resynchronizes with a fresh snapshot. Most failures kill the track outright, but a rejected // frame (too large) doesn't, and a delta against a snapshot no consumer ever saw is unreadable. - track.write(&frame)?; + let timestamp = payload.at.unwrap_or_else(moq_net::Timestamp::now); + track.write(timestamp, &frame)?; + let size = frame.payload.len(); frame.commit(); - Ok(()) + Ok(Some(size)) } fn finish(&mut self) -> Result<()> { @@ -365,8 +374,8 @@ impl Track { Ok(()) } - /// Write one encoded frame, rolling a group when it's a snapshot. - fn write(&mut self, encoded: &Encoded) -> Result<()> { + /// Write one encoded frame at `timestamp`, rolling a group when it's a snapshot. + fn write(&mut self, timestamp: moq_net::Timestamp, encoded: &Encoded) -> Result<()> { // Check before touching a group. `write_snapshot` closes the previous group and publishes a // new one before the frame is written, so discovering the limit inside `write_frame` would // leave an empty newest group behind: a snapshot consumer jumps to the newest, so the previous @@ -376,20 +385,20 @@ impl Track { } match encoded.keyframe { - true => self.write_snapshot(encoded.payload.clone()), - false => self.write_delta(encoded.payload.clone()), + true => self.write_snapshot(timestamp, encoded.payload.clone()), + false => self.write_delta(timestamp, encoded.payload.clone()), } } /// Close the open group and write a snapshot as the first frame of a new one. - fn write_snapshot(&mut self, payload: bytes::Bytes) -> Result<()> { + fn write_snapshot(&mut self, timestamp: moq_net::Timestamp, payload: bytes::Bytes) -> Result<()> { // The previous group is complete; no more frames will be appended to it. if let Some(group) = self.group.take() { group.finish()?; } let mut group = self.inner.append_group()?; - if let Err(err) = group.write_frame(moq_net::Timestamp::now(), payload) { + if let Err(err) = group.write_frame(timestamp, payload) { // `append_group` already published this group, and a rejected frame (too large) doesn't // close the track. Dropping the handle does NOT close the group, so leaving it would strand // any subscriber that advanced into it with nothing to read and no end. @@ -408,11 +417,11 @@ impl Track { } /// Append a delta to the group the last snapshot opened. - fn write_delta(&mut self, payload: bytes::Bytes) -> Result<()> { + fn write_delta(&mut self, timestamp: moq_net::Timestamp, payload: bytes::Bytes) -> Result<()> { self.group .as_mut() .expect("the encoder only emits a delta after a snapshot opened a group") - .write_frame(moq_net::Timestamp::now(), payload)?; + .write_frame(timestamp, payload)?; Ok(()) } diff --git a/rs/moq-json/src/stream/mod.rs b/rs/moq-json/src/stream/mod.rs index 90753191fb..5c2e217fa3 100644 --- a/rs/moq-json/src/stream/mod.rs +++ b/rs/moq-json/src/stream/mod.rs @@ -116,6 +116,26 @@ mod test { assert_eq!(records, (0..5).map(|n| json!({ "n": n })).collect::>()); } + /// Each record keeps its own capture time, and the returned size is the encoded frame. + #[test] + fn a_stamped_append_writes_its_capture_time() { + let (mut producer, _track) = producer(compressed()); + let mut groups = producer.consume(); + let captured = moq_net::Timestamp::from_millis(1_234).unwrap(); + let record = json!({ "n": 1 }); + let size = producer.append(moq_net::Timed::from(&record).at(captured)).unwrap(); + + let waiter = kio::Waiter::noop(); + let Poll::Ready(Ok(Some(mut group))) = groups.poll_recv_group(&waiter) else { + panic!("expected a group"); + }; + let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) else { + panic!("expected a frame"); + }; + assert_eq!(frame.timestamp.as_micros(), captured.as_micros()); + assert_eq!(size, frame.payload.len()); + } + #[test] fn compressed_roundtrip_in_order() { let (mut producer, track) = producer(compressed()); diff --git a/rs/moq-json/src/stream/producer.rs b/rs/moq-json/src/stream/producer.rs index f5de6e74a6..6e9626abd6 100644 --- a/rs/moq-json/src/stream/producer.rs +++ b/rs/moq-json/src/stream/producer.rs @@ -6,6 +6,8 @@ use std::sync::{Arc, Mutex}; use serde::Serialize; use super::Encoder; +use moq_net::Timed; + use crate::Result; pub use super::Config; @@ -70,8 +72,13 @@ impl Producer { /// failure is surfaced rather than papered over with a second group. The track is aborted rather /// than closed cleanly, so a consumer sees the failure instead of a log that merely looks /// complete. Every later append fails on the ended track. - pub fn append(&mut self, value: &T) -> Result<()> { - self.inner.lock().unwrap().append(value) + /// + /// Returns the encoded size of the frame written. + pub fn append<'a>(&mut self, value: impl Into>) -> Result + where + T: 'a, + { + self.inner.lock().unwrap().append(value.into()) } /// Finish the track, closing the group. @@ -91,14 +98,14 @@ struct Inner { } impl Inner { - fn append(&mut self, value: &T) -> Result<()> { + fn append(&mut self, payload: Timed<&T>) -> Result { // Split the borrow so `record` can hold the encoder while `track` is written through. let Inner { track, encoder } = self; // Encode first, so a value that can't be serialized doesn't publish an empty group that // subscribers would advance into and wait on. Opening the group afterwards is safe because // `record` guards the window: any failure below drops it uncommitted. - let record = match encoder.encode(value) { + let record = match encoder.encode(payload.value) { Ok(record) => record, Err(err) => { // A record that can't be encoded is as lost as one the group rejects: the log is @@ -112,7 +119,7 @@ impl Inner { let opened = track.open(); let published = opened.is_ok(); let result = match opened { - Ok(()) => track.write(record.payload()), + Ok(()) => track.write(payload.at.unwrap_or_else(moq_net::Timestamp::now), record.payload()), Err(err) => Err(err), }; @@ -138,8 +145,9 @@ impl Inner { return Err(err.into()); } + let size = record.payload().len(); record.commit(); - Ok(()) + Ok(size) } fn finish(&mut self) -> Result<()> { @@ -164,10 +172,14 @@ impl Track { Ok(()) } - /// Append one encoded record to the log's group. - fn write(&mut self, payload: &bytes::Bytes) -> std::result::Result<(), moq_net::Error> { + /// Append one encoded record to the log's group at `timestamp`. + fn write( + &mut self, + timestamp: moq_net::Timestamp, + payload: &bytes::Bytes, + ) -> std::result::Result<(), moq_net::Error> { let group = self.group.as_mut().expect("a group is open"); - group.write_frame(moq_net::Timestamp::now(), payload.clone()) + group.write_frame(timestamp, payload.clone()) } /// End the track with an error, so a consumer sees the failure rather than a clean end. diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index 247d789404..851eb1a60d 100644 --- a/rs/moq-loc/CHANGELOG.md +++ b/rs/moq-loc/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.16](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.15...moq-loc-v0.2.16) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + +## [0.2.15](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.14...moq-loc-v0.2.15) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.2.14](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.13...moq-loc-v0.2.14) - 2026-09-26 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index 11d260f4be..ca491c7795 100644 --- a/rs/moq-loc/Cargo.toml +++ b/rs/moq-loc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.14" +version = "0.2.16" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-msf/CHANGELOG.md b/rs/moq-msf/CHANGELOG.md index 03c0126281..714fe9efd4 100644 --- a/rs/moq-msf/CHANGELOG.md +++ b/rs/moq-msf/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.1](https://github.com/moq-dev/moq/compare/moq-msf-v0.5.0...moq-msf-v0.5.1) - 2026-09-26 + +### Added + +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + ## [0.5.0](https://github.com/moq-dev/moq/compare/moq-msf-v0.4.2...moq-msf-v0.5.0) - 2026-09-23 ### Added diff --git a/rs/moq-msf/Cargo.toml b/rs/moq-msf/Cargo.toml index 81ec871fe5..b93878fd68 100644 --- a/rs/moq-msf/Cargo.toml +++ b/rs/moq-msf/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.0" +version = "0.5.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index bf54e97eb3..f4ef743a18 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.8](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.7...moq-mux-v0.10.8) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) + +### Fixed + +- *(fmp4)* carry Opus pre-skip and gain through dOps ([#4294](https://github.com/moq-dev/moq/pull/4294)) +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + +## [0.10.7](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.6...moq-mux-v0.10.7) - 2026-09-26 + +### Added + +- *(mux)* forward importer discontinuities through publishers ([#4239](https://github.com/moq-dev/moq/pull/4239)) +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) + ## [0.10.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.5...moq-mux-v0.10.6) - 2026-09-26 ### Other diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index f3a46c917e..315fad5b99 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.6" +version = "0.10.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index d0ad13a996..75b979df88 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -30,6 +30,21 @@ //! The catalog entry is written when the producer is created and removed when it drops, so a track //! is never advertised without a publisher behind it. //! +//! A payload that carries the [`Instant`] it was captured is written at that +//! time on the broadcast [`Clock`](crate::Clock), and the entry advertises how late payloads reach +//! the transport as its `jitter` and `delay`, the way a media rendition does: +//! +//! ```no_run +//! # fn example( +//! # thumbnail: &mut moq_mux::binary::Snapshot, +//! # jpeg: bytes::Bytes, +//! # captured: std::time::Instant, +//! # ) -> moq_mux::Result<()> { +//! thumbnail.update(moq_net::Timed::from(jpeg).at(captured))?; +//! # Ok(()) +//! # } +//! ``` +//! //! Read one back off the catalog, naming it once: //! //! ```no_run @@ -48,8 +63,10 @@ //! ``` use std::marker::PhantomData; +use std::time::Instant; use bytes::Bytes; +use moq_net::Timed; use hang::catalog::{BinaryConfig, Compression, Mode}; @@ -121,6 +138,8 @@ fn prepare(config: &mut impl AsMut, mode: Mode) -> crate::Result { inner: moq_binary::snapshot::Producer, listing: Listing, + /// Maps a payload's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -136,10 +155,12 @@ impl Snapshot { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::snapshot::Producer::new(track, binary); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, listing, + clock, _catalog: PhantomData, }) } @@ -155,11 +176,13 @@ impl Snapshot { } /// Publish a new payload, superseding the previous one. - pub fn update(&mut self, payload: impl Into) -> crate::Result<()> { - let payload = payload.into(); - let len = payload.len(); - self.inner.update(payload)?; - self.listing.record(|| len) + /// + /// A payload timed with its capture instant is written at that time and measures the entry's + /// `jitter` and `delay`; one ahead of now is refused before anything is written. + pub fn update(&mut self, payload: impl Into>) -> crate::Result<()> { + let (payload, captured) = self.clock.stamp(payload.into())?; + let size = self.inner.update(payload)?; + self.listing.record(size, captured) } /// Finish the track and retire its catalog entry. @@ -184,6 +207,8 @@ pub struct Stream { /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. listing: Option, + /// Maps a payload's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -199,11 +224,13 @@ impl Stream { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::stream::Producer::new(track, binary); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, name: listing.name().to_string(), listing: Some(listing), + clock, _catalog: PhantomData, }) } @@ -227,20 +254,22 @@ impl Stream { /// [`moq_binary::stream::Producer::append`]) and retires the catalog entry with it. A catalog /// error publishing the measured bitrate is returned after the payload was written, so the track /// stays open and a retry would duplicate it. - pub fn append(&mut self, payload: impl Into) -> crate::Result<()> { - let payload = payload.into(); - let len = payload.len(); - if let Err(err) = self.inner.append(payload) { - // The inner producer has already closed the track. Dropping the listing retires the - // catalog entry too: waiting for the handle to drop would keep advertising a track that - // can no longer accept records, so a consumer discovering it now would subscribe to an - // already-ended log. - self.listing = None; - return Err(err.into()); - } + pub fn append(&mut self, payload: impl Into>) -> crate::Result<()> { + let (payload, captured) = self.clock.stamp(payload.into())?; + let size = match self.inner.append(payload) { + Ok(size) => size, + Err(err) => { + // The inner producer has already closed the track. Dropping the listing retires the + // catalog entry too: waiting for the handle to drop would keep advertising a track + // that can no longer accept records, so a consumer discovering it now would subscribe + // to an already-ended log. + self.listing = None; + return Err(err.into()); + } + }; match &mut self.listing { - Some(listing) => listing.record(|| len), + Some(listing) => listing.record(size, captured), None => Ok(()), } } @@ -390,6 +419,103 @@ mod test { out } + /// A track whose payloads reach the transport later than another's captures advertises the gap + /// as `delay`, while one written without capture times advertises neither `delay` nor `jitter`. + #[test] + fn a_late_capture_is_delay() { + let (mut broadcast, catalog) = catalog(); + let mut fast = catalog + .binary_stream(track(&mut broadcast, "fast"), Config::default()) + .unwrap(); + let mut slow = catalog + .binary_stream(track(&mut broadcast, "slow"), Config::default()) + .unwrap(); + let mut bare = catalog + .binary_stream(track(&mut broadcast, "bare"), Config::default()) + .unwrap(); + + let anchor = std::time::Instant::now(); + for i in 0..20u64 { + let now = moq_net::Timestamp::from_micros(i * 100_000).unwrap(); + let at = |late: u64| anchor + std::time::Duration::from_millis(i * 100 + late); + let fast = fast.listing.as_mut().unwrap(); + fast.record_at(now, 100, Some((now, at(0)))).unwrap(); + let slow = slow.listing.as_mut().unwrap(); + slow.record_at(now, 100, Some((now, at(200)))).unwrap(); + bare.listing.as_mut().unwrap().record_at(now, 100, None).unwrap(); + } + + assert_eq!(entry(&catalog, "fast").delay, None); + assert_eq!( + entry(&catalog, "slow").delay, + Some(std::time::Duration::from_millis(200)) + ); + assert_eq!( + entry(&catalog, "slow").jitter, + None, + "a constant lateness is not jitter" + ); + let bare = entry(&catalog, "bare"); + assert_eq!((bare.delay, bare.jitter), (None, None)); + } + + /// A captured payload measures through the real clock: written right after capture, it + /// advertises no delay beyond the scheduling noise of the test itself. + #[test] + fn a_capture_measures_through_the_clock() { + let (mut broadcast, catalog) = catalog(); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), Config::default()) + .unwrap(); + + let captured = std::time::Instant::now(); + telemetry.append(Timed::from(&b"now"[..]).at(captured)).unwrap(); + let late = captured - std::time::Duration::from_secs(1); + telemetry.append(Timed::from(&b"late"[..]).at(late)).unwrap(); + + // A capture ahead of now is refused before anything is written. + let ahead = std::time::Instant::now() + std::time::Duration::from_secs(1); + assert!(matches!( + telemetry.append(Timed::from(&b"ahead"[..]).at(ahead)), + Err(crate::Error::InvalidCapture) + )); + + let jitter = entry(&catalog, "telemetry").jitter.expect("a late capture is jitter"); + assert!(jitter >= std::time::Duration::from_secs(1), "{jitter:?}"); + } + + /// An untimed payload is stamped on the broadcast clock too, so mixing it with captured payloads + /// keeps the track on one timeline. + #[test] + fn an_untimed_payload_shares_the_broadcast_clock() { + let (mut broadcast, catalog) = catalog(); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), Config::default()) + .unwrap(); + let mut subscriber = telemetry.consume(); + + let before = catalog.clock().now(); + telemetry.append(&b"untimed"[..]).unwrap(); + telemetry + .append(Timed::from(&b"timed"[..]).at(std::time::Instant::now())) + .unwrap(); + let after = catalog.clock().now(); + + let waiter = kio::Waiter::noop(); + let mut stamps = Vec::new(); + while let Poll::Ready(Ok(Some(mut group))) = subscriber.poll_recv_group(&waiter) { + while let Poll::Ready(Ok(Some(frame))) = group.poll_read_frame(&waiter) { + stamps.push(frame.timestamp.as_millis()); + } + } + assert_eq!(stamps.len(), 2); + assert!( + // The track stores milliseconds. + before.as_millis() <= stamps[0] && stamps[0] <= stamps[1] && stamps[1] <= after.as_millis(), + "{before:?} {stamps:?} {after:?}" + ); + } + #[test] fn a_stream_track_roundtrips() { let (mut broadcast, catalog) = catalog(); @@ -632,7 +758,7 @@ mod test { // 40ms payloads of 5 kB: 1 Mbps, over more than the bitrate window. for i in 0..60u64 { let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); - telemetry.listing.as_mut().unwrap().record_at(now, 5_000).unwrap(); + telemetry.listing.as_mut().unwrap().record_at(now, 5_000, None).unwrap(); } let entry = &catalog.snapshot().ext.mavlink["telemetry"]; @@ -640,10 +766,9 @@ mod test { assert_eq!(entry.binary.jitter, None, "write spacing is not a flush delay"); } - /// A supplied bitrate is authoritative, so writes aren't measured at all: for JSON that would - /// be a second serialization per write, for nothing. + /// A supplied bitrate is authoritative, while a capture time still measures jitter and delay. #[test] - fn a_supplied_bitrate_skips_measurement() { + fn a_supplied_bitrate_is_kept() { let (mut broadcast, catalog) = catalog(); let mut entry = mavlink(1); entry.binary.bitrate = Some(64_000); @@ -652,9 +777,17 @@ mod test { .unwrap(); let listing = telemetry.listing.as_mut().unwrap(); - listing - .record(|| panic!("measured a write despite a supplied bitrate")) - .unwrap(); + let anchor = std::time::Instant::now(); + for i in 0..60u64 { + let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); + // Every other payload reaches the transport 10ms later than the rest. + let written = anchor + std::time::Duration::from_micros(i * 40_000 + (i % 2) * 10_000); + listing.record_at(now, 5_000, Some((now, written))).unwrap(); + } + + let entry = &catalog.snapshot().ext.mavlink["telemetry"]; + assert_eq!(entry.binary.bitrate, Some(64_000)); + assert_eq!(entry.binary.jitter, Some(std::time::Duration::from_millis(10))); assert_eq!(catalog.snapshot().ext.mavlink["telemetry"].binary.bitrate, Some(64_000)); } } diff --git a/rs/moq-mux/src/catalog/data.rs b/rs/moq-mux/src/catalog/data.rs index 10f73b0a0b..4f84d42f80 100644 --- a/rs/moq-mux/src/catalog/data.rs +++ b/rs/moq-mux/src/catalog/data.rs @@ -30,15 +30,13 @@ impl + AsMut> IntoRendition for } /// A data track's catalog entry, owned for the life of its producer and kept current with the -/// bitrate its writes measure. +/// bitrate, jitter, and delay its writes measure. /// /// Erases the entry's type, so a data producer's own type doesn't depend on which section lists it. pub(crate) struct Listing { rendition: Box, + /// Measures `delay` against the catalog's other renditions, media included. estimator: Estimator, - /// Whether writes are measured: only when the entry detects its estimate and the publisher - /// didn't supply a bitrate, which detection never overrides. - measures: bool, } /// The parts of a [`Rendition`] a [`Listing`] uses, without its config type. @@ -66,12 +64,10 @@ impl Listing { mut rendition: Rendition, config: C, ) -> crate::Result { - let measures = C::detects() && config.estimate().bitrate.is_none(); rendition.set(config)?; Ok(Self { + estimator: rendition.estimator(), rendition: Box::new(rendition), - estimator: Estimator::new(), - measures, }) } @@ -80,49 +76,31 @@ impl Listing { self.rendition.name() } - /// Measure a write of `bytes`, stamped on the broadcast clock. - /// - /// `bytes` is only evaluated for an entry that measures its bitrate, since measuring can cost a - /// second serialization. - pub(crate) fn record(&mut self, bytes: impl FnOnce() -> usize) -> crate::Result<()> { - if !self.measures { - return Ok(()); - } + /// Measure a frame of `bytes` encoded bytes, just written, captured at `captured` on the + /// broadcast clock if known. + pub(crate) fn record(&mut self, bytes: usize, captured: Option) -> crate::Result<()> { let now = self.rendition.timestamp()?; - self.record_at(now, bytes()) + let flush = captured.map(|captured| (captured, std::time::Instant::now())); + self.record_at(now, bytes, flush) } /// [`record`](Self::record) at a chosen time, which a test needs since the broadcast clock only - /// moves in real time. - pub(crate) fn record_at(&mut self, now: moq_net::Timestamp, bytes: usize) -> crate::Result<()> { + /// moves in real time. `flush` is the capture time and the instant the frame was written. + pub(crate) fn record_at( + &mut self, + now: moq_net::Timestamp, + bytes: usize, + flush: Option<(moq_net::Timestamp, std::time::Instant)>, + ) -> crate::Result<()> { // Each write is its own span, closed by the next one. self.estimator.cut(Some(now)); self.estimator.write(now, bytes); - // The spacing between writes is the application's cadence, not a flush delay, so only the - // bitrate is measured. A publisher that knows its jitter sets it on the entry. - let estimate = self.estimator.estimate().with_jitter(None); - self.rendition.estimate(estimate) - } -} - -/// The serialized size of `value`, as an upper bound on what a JSON write puts on the wire: -/// compression and deltas only shrink it. -pub(crate) fn json_len(value: &T) -> usize { - struct Count(usize); - - impl std::io::Write for Count { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0 += buf.len(); - Ok(buf.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) + // The spacing between writes is the application's cadence, not a flush delay, so only a + // capture time measures jitter and delay. Without one neither is advertised. + if let Some((captured, written)) = flush { + self.estimator.flush(captured, written); } + self.rendition.estimate(self.estimator.estimate()) } - - let mut count = Count(0); - // Only reached after the producer serialized the same value, so this cannot fail. - let _ = serde_json::to_writer(&mut count, value); - count.0 } diff --git a/rs/moq-mux/src/catalog/mod.rs b/rs/moq-mux/src/catalog/mod.rs index 88ef14f672..a3f83eddc3 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -37,7 +37,7 @@ pub(crate) mod tracks; pub(crate) use claim::Claim; pub use consumer::Consumer; pub use data::IntoRendition; -pub(crate) use data::{Listing, json_len}; +pub(crate) use data::Listing; pub use entry::Entry; pub use estimate::{Estimate, Estimator}; pub use format::*; diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 91c0670fcc..2e040a17ba 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -51,16 +51,20 @@ use super::hang::{Catalog, CatalogExt}; /// catalog.ext.mavlink.remove(name); /// } /// -/// // Opt into bitrate detection through the embedded config. +/// // Opt into detection through the embedded config. /// fn detects() -> bool { /// true /// } /// fn estimate(&self) -> Estimate { -/// Estimate::default().with_bitrate(self.binary.bitrate).with_jitter(self.binary.jitter) +/// Estimate::default() +/// .with_bitrate(self.binary.bitrate) +/// .with_jitter(self.binary.jitter) +/// .with_delay(self.binary.delay) /// } /// fn set_estimate(&mut self, estimate: Estimate) { /// self.binary.bitrate = estimate.bitrate; /// self.binary.jitter = estimate.jitter; +/// self.binary.delay = estimate.delay; /// } /// } /// @@ -122,11 +126,15 @@ impl RenditionConfig for hang::catalog::JsonConfig { true } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + Estimate::default() + .with_jitter(self.jitter) + .with_bitrate(self.bitrate) + .with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; self.bitrate = estimate.bitrate; + self.delay = estimate.delay; } } @@ -145,11 +153,15 @@ impl RenditionConfig for hang::catalog::BinaryConfig { true } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + Estimate::default() + .with_jitter(self.jitter) + .with_bitrate(self.bitrate) + .with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; self.bitrate = estimate.bitrate; + self.delay = estimate.delay; } } @@ -536,6 +548,11 @@ impl> Rendition { self.catalog.estimator() } + /// The broadcast clock the catalog stamps its tracks on. + pub(crate) fn clock(&self) -> crate::Clock { + self.catalog.clock() + } + /// Resolve a timestamp on the broadcast's shared clock (see [`Producer::timestamp`]). pub fn timestamp(&self, hint: Option) -> crate::Result { self.catalog.timestamp(hint) diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs index 39961a2773..ee2e0694b8 100644 --- a/rs/moq-mux/src/clock.rs +++ b/rs/moq-mux/src/clock.rs @@ -97,6 +97,38 @@ impl Clock { .expect("an instant elapsed duration fits in a timestamp") } + /// Map the instant a payload was captured (a datagram's arrival, a sensor read) onto this clock. + /// + /// Refuses an instant ahead of now, which would claim the payload reached the transport before + /// it existed, and one before the clock's epoch, which no timestamp can name. + pub(crate) fn capture(&self, at: Instant) -> crate::Result { + if at > Instant::now() { + return Err(crate::Error::InvalidCapture); + } + let elapsed = at + .checked_duration_since(self.epoch) + .ok_or(crate::Error::InvalidCapture)?; + Ok(moq_net::Timestamp::from_micros(elapsed.as_micros() as u64) + .expect("an instant elapsed duration fits in a timestamp")) + } + + /// Map a payload's capture instant onto this clock, stamping an untimed payload now, so timed + /// and untimed writes share one timeline. Also returns the capture time, if there was one. + pub(crate) fn stamp

( + &self, + timed: moq_net::Timed, + ) -> crate::Result<(moq_net::Timed

, Option)> { + let captured = timed.at.map(|at| self.capture(at)).transpose()?; + let at = captured.unwrap_or_else(|| self.now()); + Ok(( + moq_net::Timed { + value: timed.value, + at: Some(at), + }, + captured, + )) + } + /// Units per second for [`wall`](Self::wall): [`TIMESCALE`](Self::TIMESCALE). pub fn timescale(&self) -> moq_net::Timescale { Self::TIMESCALE @@ -383,6 +415,24 @@ mod tests { assert_eq!(clock.wall(), shared.wall()); } + /// A capture maps onto the clock's own timeline, and one ahead of now or before the epoch is + /// refused rather than clamped. + #[test] + fn a_capture_maps_onto_the_clock() { + let now = Instant::now(); + let clock = Clock::at(now - Duration::from_secs(5), moq_epoch()).unwrap(); + assert_eq!(clock.capture(now - Duration::from_secs(2)).unwrap(), us(3_000_000)); + + assert!(matches!( + clock.capture(Instant::now() + Duration::from_secs(1)), + Err(crate::Error::InvalidCapture) + )); + assert!(matches!( + clock.capture(now - Duration::from_secs(6)), + Err(crate::Error::InvalidCapture) + )); + } + #[test] fn section_advertises_wall_in_clock_timescale() { // PTS zero at exactly the moq epoch advertises 0. diff --git a/rs/moq-mux/src/container/flv/export.rs b/rs/moq-mux/src/container/flv/export.rs index 72c7bcb4a5..73491f6ae6 100644 --- a/rs/moq-mux/src/container/flv/export.rs +++ b/rs/moq-mux/src/container/flv/export.rs @@ -10,7 +10,8 @@ //! other codecs through unchanged. //! //! By default FLV carries a single video and a single audio stream, so only the -//! first rendition of each kind is muxed and the rest are ignored. With +//! best video rendition (see [`Video::ranked`](hang::catalog::Video::ranked)) and +//! the first audio rendition are muxed and the rest are ignored. With //! [`with_multitrack`](Export::with_multitrack) every rendition is muxed instead, //! each as an enhanced-RTMP multitrack track addressed by its own track id (use //! this only for a player that advertised the `Multitrack` capability). @@ -105,8 +106,10 @@ pub struct Export { catalog: Option, max_age: std::time::Duration, /// Emit every rendition as an enhanced-RTMP multitrack track, rather than only - /// the first video + first audio rendition. + /// the best video + first audio rendition. multitrack: bool, + /// Only mux the renditions this selects, or every rendition when unset. + select: Option, video: Vec, audio: Vec, @@ -181,6 +184,7 @@ impl Export { catalog: Some(catalog), max_age: std::time::Duration::ZERO, multitrack: false, + select: None, video: Vec::new(), audio: Vec::new(), header_emitted: false, @@ -198,7 +202,7 @@ impl Export { } /// Mux every rendition as an enhanced-RTMP multitrack track (one FLV stream - /// carrying several video and/or audio tracks), rather than only the first + /// carrying several video and/or audio tracks), rather than only the best /// video + first audio rendition. /// /// Only enable this for a player that advertised the enhanced-RTMP @@ -209,6 +213,15 @@ impl Export { self } + /// Only mux the renditions `select` keeps, such as the codecs a player can decode. + /// + /// A single-track stream then carries the best video rendition among them. + /// Defaults to every rendition. + pub fn with_select(mut self, select: crate::select::Broadcast) -> Self { + self.select = Some(select); + self + } + /// Get the next byte chunk. pub async fn next(&mut self) -> anyhow::Result> { kio::wait(|waiter| self.poll_next(waiter)).await @@ -304,10 +317,12 @@ impl Export { fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { self.source.retain_valid_media(&mut catalog); + if let Some(select) = &self.select { + select.retain(&mut catalog); + } - // A single-track FLV stream binds only the first rendition of each kind; - // multitrack binds them all. Bind newly-seen renditions in name order (the - // catalog is a BTreeMap) so each keeps a stable track id. + // A single-track FLV stream binds one rendition of each kind; multitrack + // binds them all. // // Only bind before the header is emitted: the sequence-header (config) tags // go out with the header, and there's no in-band way to introduce a new @@ -317,7 +332,8 @@ impl Export { if !self.header_emitted { self.bind_video(&catalog)?; self.bind_audio(&catalog)?; - } else if catalog.video.renditions.len() > self.video.len() || catalog.audio.renditions.len() > self.audio.len() + } else if self.multitrack + && (catalog.video.renditions.len() > self.video.len() || catalog.audio.renditions.len() > self.audio.len()) { tracing::warn!("ignoring FLV rendition that appeared after the stream header"); } @@ -336,9 +352,21 @@ impl Export { } fn bind_video(&mut self, catalog: &Catalog) -> anyhow::Result<()> { - for (name, config) in &catalog.video.renditions { + let renditions: Vec<_> = if self.multitrack { + // Name order (the catalog is a BTreeMap), so each keeps a stable track id. + catalog.video.renditions.iter().collect() + } else { + // The best rendition FLV can carry, whatever the names. The rest follow in + // rank order so a catalog with nothing FLV carries fails on its best one. + let mut ranked: Vec<_> = catalog.video.ranked().collect(); + ranked.sort_by_key(|(_, config)| { + video_flavor(config).is_err() || !matches!(config.container, Container::Legacy | Container::Loc) + }); + ranked + }; + + for (name, config) in renditions { if !self.multitrack && !self.video.is_empty() { - tracing::warn!("FLV export only supports one video track; ignoring the rest (enable multitrack)"); break; } if self.video.iter().any(|t| &t.name == name) { diff --git a/rs/moq-mux/src/container/flv/export_test.rs b/rs/moq-mux/src/container/flv/export_test.rs index 3c1350f3a5..147bdc165e 100644 --- a/rs/moq-mux/src/container/flv/export_test.rs +++ b/rs/moq-mux/src/container/flv/export_test.rs @@ -509,6 +509,9 @@ type Keepalive = ( /// Build a broadcast with two H.264 video renditions plus one AAC audio rendition, /// each with a single keyframe, so multitrack export has several tracks to mux. +/// +/// The first description's rendition is the smaller and sorts first by name, so +/// name order alone would pick the wrong one. fn build_multitrack_broadcast() -> (moq_net::broadcast::Consumer, Vec>, Keepalive) { use hang::catalog::{AAC, AudioConfig, Container, H264, VideoConfig}; use moq_net::Timestamp; @@ -523,7 +526,7 @@ fn build_multitrack_broadcast() -> (moq_net::broadcast::Consumer, Vec>, let mut tracks = Vec::new(); let descriptions: Vec> = vec![avcc_level(0x1f), avcc_level(0x1e)]; - for description in &descriptions { + for (description, (width, height)) in descriptions.iter().zip([(640, 360), (1920, 1080)]) { let track = producer.create_track(producer.unique_name(".avc1"), None).unwrap(); let mut config = VideoConfig::new(H264 { profile: 0x42, @@ -533,6 +536,8 @@ fn build_multitrack_broadcast() -> (moq_net::broadcast::Consumer, Vec>, }); config.container = Container::Legacy; config.description = Some(Bytes::from(description.clone())); + config.coded_width = Some(width); + config.coded_height = Some(height); catalog .modify() .unwrap() @@ -605,8 +610,7 @@ async fn drain_to_end(mut exporter: Export, keepalive: Keepalive) -> Vec { } /// With multitrack enabled, every rendition is muxed as an enhanced-RTMP -/// multitrack track and survives an export -> import round trip. Without it, only -/// the first video rendition is muxed. +/// multitrack track and survives an export -> import round trip. #[tokio::test(start_paused = true)] async fn export_multitrack_roundtrips_all_renditions() { let (consumer, descriptions, keepalive) = build_multitrack_broadcast(); @@ -665,11 +669,11 @@ async fn export_multitrack_roundtrips_all_renditions() { assert_eq!(got, want, "each rendition should keep its own avcC"); } -/// Without multitrack, a multi-rendition broadcast exports only the first video -/// rendition (the single-track fallback for a legacy player). +/// Without multitrack, a multi-rendition broadcast exports only the best video +/// rendition (the single-track fallback for a legacy player), not the first by name. #[tokio::test(start_paused = true)] -async fn export_without_multitrack_keeps_first_rendition() { - let (consumer, _, keepalive) = build_multitrack_broadcast(); +async fn export_without_multitrack_keeps_best_rendition() { + let (consumer, descriptions, keepalive) = build_multitrack_broadcast(); let exporter = Export::new(crate::source::announced(&consumer)).await.unwrap(); let exported = drain_to_end(exporter, keepalive).await; @@ -688,13 +692,62 @@ async fn export_without_multitrack_keeps_first_rendition() { imp2.decode(&bytes::BytesMut::from(exported.as_slice())).unwrap(); imp2.finish().unwrap(); + let snap = cat2.snapshot(); + let renditions: Vec<_> = snap.video.renditions.values().collect(); + assert_eq!(renditions.len(), 1, "only one rendition without multitrack"); assert_eq!( - cat2.snapshot().video.renditions.len(), - 1, - "only one rendition without multitrack" + renditions[0].description.as_deref(), + Some(descriptions[1].as_slice()), + "the larger rendition wins" ); } +/// A selection narrows what the export may carry, and a single-track export +/// picks the best rendition among what remains. +#[tokio::test(start_paused = true)] +async fn export_picks_the_best_selected_rendition() { + use crate::catalog::Stream; + + let (consumer, descriptions, keepalive) = build_multitrack_broadcast(); + + // Select only the smaller rendition, so the larger one is off the table. + let snapshot = crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap(); + let small = snapshot + .video + .renditions + .iter() + .find(|(_, config)| config.coded_height == Some(360)) + .map(|(name, _)| name.clone()) + .unwrap(); + + let select = crate::select::Broadcast::default() + .video(crate::select::Video::default().name(small)) + .audio(crate::select::Audio::default()); + let exporter = Export::new(crate::source::announced(&consumer)) + .await + .unwrap() + .with_select(select); + let exported = drain_to_end(exporter, keepalive).await; + + let mut bcast2 = moq_net::broadcast::Info::new().produce(); + let cat2 = crate::catalog::Producer::new(&mut bcast2, crate::catalog::Config::default()).unwrap(); + let mut imp2 = Import::new(bcast2, cat2.reserve()); + imp2.decode(&bytes::BytesMut::from(exported.as_slice())).unwrap(); + imp2.finish().unwrap(); + + let snap = cat2.snapshot(); + let renditions: Vec<_> = snap.video.renditions.values().collect(); + assert_eq!(renditions.len(), 1); + assert_eq!(renditions[0].description.as_deref(), Some(descriptions[0].as_slice())); + assert_eq!(snap.audio.renditions.len(), 1, "audio is selected too"); +} + struct ParsedTag { tag_type: u8, timestamp: u32, diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index c5d20ebac6..1f454c3fea 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -873,6 +873,25 @@ fn synthesize_opus_trak_preserves_pre_skip() { assert_eq!(opus.dops.pre_skip, 312); } +/// dOps has nowhere to put a channel mapping table, so a family 1 head is refused rather +/// than written as family 0 without it. +#[test] +fn synthesize_opus_trak_refuses_mapping_family() { + use hang::catalog::{AudioCodec, AudioConfig}; + + let mut head = crate::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec(); + head[18] = 1; // channel mapping family + head.extend_from_slice(&[1, 1, 0, 1]); // one coupled stream feeding both channels + let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); + config.description = Some(head.into()); + + let err = super::synthesize_audio_trak(1, 48_000, &config).unwrap_err(); + assert!(matches!( + err, + super::Error::Opus(crate::codec::opus::Error::UnsupportedMappingFamily(1)) + )); +} + /// A legacy FLAC rendition (no init segment) synthesizes a `fLaC` sample entry /// whose `dfLa` STREAMINFO is rebuilt from the catalog description. #[test] diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 15d2cf26ea..d30facd4e4 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -561,11 +561,19 @@ impl Import { config } mp4_atom::Codec::Opus(opus) => { - let mut config = AudioConfig::new( - AudioCodec::Opus, - opus.audio.sample_rate.integer() as _, - opus.audio.channel_count as _, - ); + // dOps carries the OpusHead fields in ISOBMFF byte order; republish them as the + // OpusHead description so decoders apply its pre-skip and gain. mp4-atom already + // refuses a nonzero channel mapping family, so the table is never dropped here. + let dops = &opus.dops; + let mut head = + crate::codec::opus::Config::new(dops.input_sample_rate, dops.output_channel_count as u32) + .with_pre_skip(dops.pre_skip); + head.output_gain = dops.output_gain; + + // The catalog describes the decoder's output: Opus always decodes at 48 kHz, whatever + // the sample entry or the informational input rate claims. + let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, head.channel_count); + config.description = Some(head.encode()?); config.container = container; config } diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index 9d54992e80..40c10a626d 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -554,7 +554,24 @@ fn test_flac_catalog() { }, }; - let trak = super::build_audio_trak(1, 96_000, mp4_atom::Codec::from(flac)); + let data = audio_init(super::build_audio_trak(1, 96_000, mp4_atom::Codec::from(flac))); + + let catalog = run_fmp4(&data); + assert_eq!(catalog.audio.renditions.len(), 1); + + let a = catalog.audio.renditions.values().next().unwrap(); + assert!(matches!(a.codec, hang::catalog::AudioCodec::Flac)); + assert_eq!(a.sample_rate, 96_000); + assert_eq!(a.channel_count, 2); + // fmp4 import is CMAF passthrough. + assert!(matches!(a.container, Container::Cmaf { .. })); + // The WebCodecs FLAC description: `fLaC` marker + STREAMINFO. + let desc = a.description.as_ref().expect("flac description"); + assert_eq!(&desc[..4], b"fLaC"); +} + +/// An init segment (ftyp + moov) holding a single audio trak with track ID 1. +fn audio_init(trak: mp4_atom::Trak) -> Vec { let moov = mp4_atom::Moov { mvhd: mp4_atom::Mvhd { timescale: 1000, @@ -580,19 +597,83 @@ fn test_flac_catalog() { let mut data = Vec::new(); ftyp.encode(&mut data).unwrap(); moov.encode(&mut data).unwrap(); + data +} - let catalog = run_fmp4(&data); - assert_eq!(catalog.audio.renditions.len(), 1); +/// An Opus init segment whose dOps declares a 44.1 kHz input, 312 samples of pre-skip, +/// and -6 dB of gain, behind a sample entry claiming `entry_rate`. +fn opus_init(entry_rate: u16) -> (mp4_atom::Dops, Vec) { + let dops = mp4_atom::Dops { + output_channel_count: 2, + pre_skip: 312, + input_sample_rate: 44_100, + output_gain: -1536, + }; + let opus = mp4_atom::Opus { + audio: mp4_atom::Audio { + data_reference_index: 1, + channel_count: 2, + sample_size: 16, + sample_rate: mp4_atom::FixedPoint::from(entry_rate), + }, + dops: dops.clone(), + btrt: None, + }; + let data = audio_init(super::build_audio_trak(1, 48_000, mp4_atom::Codec::from(opus))); + (dops, data) +} - let a = catalog.audio.renditions.values().next().unwrap(); - assert!(matches!(a.codec, hang::catalog::AudioCodec::Flac)); - assert_eq!(a.sample_rate, 96_000); +/// dOps becomes the OpusHead description, and a track re-exported from that description +/// writes the same dOps back, so pre-skip and gain survive the round trip. +#[test] +fn opus_dops_round_trips() { + let (dops, data) = opus_init(48_000); + + let catalog = run_fmp4(&data); + let a = catalog.audio.renditions.values().next().expect("opus rendition"); + assert!(matches!(a.codec, hang::catalog::AudioCodec::Opus)); + assert_eq!(a.sample_rate, 48_000); assert_eq!(a.channel_count, 2); - // fmp4 import is CMAF passthrough. - assert!(matches!(a.container, Container::Cmaf { .. })); - // The WebCodecs FLAC description: `fLaC` marker + STREAMINFO. - let desc = a.description.as_ref().expect("flac description"); - assert_eq!(&desc[..4], b"fLaC"); + + let desc = a.description.as_ref().expect("opus description"); + let head = crate::codec::opus::Config::parse(&mut desc.as_ref()).unwrap(); + assert_eq!(head.sample_rate, 44_100); + assert_eq!(head.channel_count, 2); + assert_eq!(head.pre_skip, 312); + assert_eq!(head.output_gain, -1536); + + let trak = super::synthesize_audio_trak(1, 48_000, a).expect("synthesize Opus trak"); + match &trak.mdia.minf.stbl.stsd.codecs[0] { + mp4_atom::Codec::Opus(opus) => assert_eq!(opus.dops, dops), + other => panic!("expected Opus sample entry, got {other:?}"), + } +} + +/// The catalog describes the decoder's 48 kHz output even when the sample entry claims +/// another rate. +#[test] +fn opus_catalog_uses_the_decode_rate() { + let (_, data) = opus_init(44_100); + let catalog = run_fmp4(&data); + let a = catalog.audio.renditions.values().next().expect("opus rendition"); + assert_eq!(a.sample_rate, 48_000); +} + +/// A dOps with a channel mapping table is refused rather than imported without it. +#[test] +fn opus_dops_mapping_family_is_refused() { + let (_, mut data) = opus_init(48_000); + + // The mapping family byte follows version, channels, pre-skip, rate, and gain. + let at = data.windows(4).position(|w| w == b"dOps").unwrap() + 4 + 10; + assert_eq!(data[at], 0); + data[at] = 1; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); + let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()); + assert!(fmp4.decode(&bytes::BytesMut::from(data.as_slice())).is_err()); + assert!(catalog.snapshot().audio.renditions.is_empty()); } // ---- Segment-driven grouping ---- diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 30af4a65c1..f44a39e698 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -715,20 +715,24 @@ pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &Audi let sample_entry = match &config.codec { AudioCodec::Opus => { - let pre_skip = match &config.description { + let head = match &config.description { Some(description) => { - let mut description = description.as_ref(); - crate::codec::opus::Config::parse(&mut description)?.pre_skip + let head = crate::codec::opus::Config::parse(&mut description.as_ref())?; + // dOps shares OpusHead's family 0 layout; a mapping table would need writing too. + if head.mapping_family != 0 { + return Err(crate::codec::opus::Error::UnsupportedMappingFamily(head.mapping_family).into()); + } + head } - None => 0, + None => crate::codec::opus::Config::new(config.sample_rate, config.channel_count), }; mp4_atom::Codec::from(mp4_atom::Opus { audio, dops: mp4_atom::Dops { output_channel_count: config.channel_count as u8, - pre_skip, - input_sample_rate: config.sample_rate, - output_gain: 0, + pre_skip: head.pre_skip, + input_sample_rate: head.sample_rate, + output_gain: head.output_gain, }, btrt: None, }) diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 9603dfa5e5..fd676051a9 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -245,6 +245,10 @@ pub enum Error { /// A rendition tried to lower delay already advertised to subscribers. #[error("catalog delay cannot decrease for a published rendition")] DelayDecreased, + + /// A capture instant is ahead of the broadcast clock's now, or before its epoch. + #[error("capture time is outside the broadcast clock")] + InvalidCapture, } impl Error { diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 9d6572eb20..96deb633c2 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -31,6 +31,21 @@ //! The catalog entry is written when the producer is created and removed when it drops, so a track //! is never advertised without a publisher behind it. //! +//! A value that carries the [`Instant`] it was captured is written at that +//! time on the broadcast [`Clock`](crate::Clock), and the entry advertises how late values reach +//! the transport as its `jitter` and `delay`, the way a media rendition does: +//! +//! ```no_run +//! # fn example( +//! # gps: &mut moq_mux::json::Stream, +//! # fix: serde_json::Value, +//! # received: std::time::Instant, +//! # ) -> moq_mux::Result<()> { +//! gps.append(moq_net::Timed::from(&fix).at(received))?; +//! # Ok(()) +//! # } +//! ``` +//! //! Read one back off the catalog, naming it once: //! //! ```no_run @@ -52,7 +67,9 @@ //! ``` use std::marker::PhantomData; +use std::time::Instant; +use moq_net::Timed; use serde::Serialize; use serde::de::DeserializeOwned; @@ -148,6 +165,8 @@ fn delta_ratio_of(config: &C) -> Option { pub struct Snapshot { inner: moq_json::snapshot::Producer, listing: Listing, + /// Maps a value's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -173,10 +192,12 @@ impl Snapshot { json.delta_ratio = delta_ratio; } let inner = moq_json::snapshot::Producer::new(track, json); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, listing, + clock, _catalog: PhantomData, }) } @@ -197,9 +218,19 @@ impl Snapshot { } /// Publish a new value, superseding the previous one. - pub fn update(&mut self, value: &T) -> crate::Result<()> { - self.inner.update(value)?; - self.listing.record(|| crate::catalog::json_len(value)) + /// + /// A value timed with its capture instant is written at that time and measures the entry's + /// `jitter` and `delay`; one ahead of now is refused before anything is written. An unchanged + /// value writes nothing and measures nothing. + pub fn update<'a>(&mut self, value: impl Into>) -> crate::Result<()> + where + T: 'a, + { + let (value, captured) = self.clock.stamp(value.into())?; + match self.inner.update(value)? { + Some(size) => self.listing.record(size, captured), + None => Ok(()), + } } /// Finish the track and retire its catalog entry. @@ -224,6 +255,8 @@ pub struct Stream { /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. listing: Option

, + /// Maps a record's capture instant onto the broadcast timeline. + clock: crate::Clock, /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. _catalog: PhantomData E>, } @@ -239,11 +272,13 @@ impl Stream { json.compression = moq_json::Compression::Deflate; } let inner = moq_json::stream::Producer::new(track, json); + let clock = rendition.clock(); let listing = Listing::new(rendition, config)?; Ok(Self { inner, name: listing.name().to_string(), listing: Some(listing), + clock, _catalog: PhantomData, }) } @@ -271,17 +306,25 @@ impl Stream { /// A record that cannot be written ends the track (see [`moq_json::stream::Producer::append`]) /// and retires the catalog entry with it. A catalog error publishing the measured bitrate is /// returned after the record was written, so the track stays open and a retry would duplicate it. - pub fn append(&mut self, value: &T) -> crate::Result<()> { - if let Err(err) = self.inner.append(value) { - // The inner producer has already ended the track. Dropping the listing retires the catalog - // entry: waiting for the handle to drop would keep advertising a track that can no longer - // accept records, so a consumer discovering it now would subscribe to an already-ended log. - self.listing = None; - return Err(err.into()); - } + pub fn append<'a>(&mut self, value: impl Into>) -> crate::Result<()> + where + T: 'a, + { + let (value, captured) = self.clock.stamp(value.into())?; + let size = match self.inner.append(value) { + Ok(size) => size, + Err(err) => { + // The inner producer has already ended the track. Dropping the listing retires the + // catalog entry: waiting for the handle to drop would keep advertising a track that + // can no longer accept records, so a consumer discovering it now would subscribe to + // an already-ended log. + self.listing = None; + return Err(err.into()); + } + }; match &mut self.listing { - Some(listing) => listing.record(|| crate::catalog::json_len(value)), + Some(listing) => listing.record(size, captured), None => Ok(()), } } @@ -602,8 +645,8 @@ mod test { // 40ms records of 500 bytes: 100 kbps, over more than the bitrate window. for i in 0..60u64 { let now = moq_net::Timestamp::from_micros(i * 40_000).unwrap(); - gps.listing.as_mut().unwrap().record_at(now, 500).unwrap(); - status.listing.record_at(now, 500).unwrap(); + gps.listing.as_mut().unwrap().record_at(now, 500, None).unwrap(); + status.listing.record_at(now, 500, None).unwrap(); } assert_eq!(entry(&catalog, "gps").bitrate, Some(100_000)); @@ -615,6 +658,28 @@ mod test { ); } + /// An unchanged snapshot value writes no frame, so its capture time must not measure a flush + /// that never happened. + #[test] + fn an_unchanged_snapshot_measures_nothing() { + let (mut broadcast, catalog) = catalog(); + let mut status = catalog + .json_snapshot::(track(&mut broadcast, "status"), Config::default()) + .unwrap(); + let now = std::time::Instant::now(); + let value = serde_json::json!({ "armed": true }); + status.update(Timed::from(&value).at(now)).unwrap(); + let stale = now - std::time::Duration::from_secs(1); + status.update(Timed::from(&value).at(stale)).unwrap(); + assert_eq!(entry(&catalog, "status").jitter, None); + + // The same stale capture on a changed value is measured. + let changed = serde_json::json!({ "armed": false }); + status.update(Timed::from(&changed).at(stale)).unwrap(); + let jitter = entry(&catalog, "status").jitter.expect("a late capture is jitter"); + assert!(jitter >= std::time::Duration::from_secs(1), "{jitter:?}"); + } + /// The catalog is the only thing that announces a data track, so walking it is the discovery /// path. Each entry carries its own name, so nothing has to be threaded alongside it. #[test] diff --git a/rs/moq-net/AGENTS.md b/rs/moq-net/AGENTS.md index ed248fc94c..1fbbd48ffa 100644 --- a/rs/moq-net/AGENTS.md +++ b/rs/moq-net/AGENTS.md @@ -11,6 +11,6 @@ The wire layer. Generic over the transport and media-agnostic: the relay never i # Testing -- `just rs fuzz ` (`lite`, `ietf`, `varint`, `path`) needs nightly. The target bodies live in `src/fuzz.rs`; `fuzz/regressions//` replays under `just check`, so commit every crash input there. +- `just rs fuzz ` (one per file in `fuzz/fuzz_targets/`) needs nightly. The target bodies live in `src/fuzz.rs`; `fuzz/regressions//` replays under `just check`, so commit every crash input there. - `just rs loom` model-checks the concurrent handoffs. A hang is a lost wakeup, not a flake. - `just test interop --all` runs the cross-language interop matrix after a wire change. diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 607907fe5a..e60aef120e 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-net-v0.3.6...moq-net-v0.3.7) - 2026-09-27 + +### Added + +- *(mux)* detect delay and jitter on JSON and binary tracks ([#4270](https://github.com/moq-dev/moq/pull/4270)) +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(net)* settle lite-07 tails on stream counts ([#4224](https://github.com/moq-dev/moq/pull/4224)) +- *(net)* announce a covering route to a narrower prefix instead of panicking ([#4302](https://github.com/moq-dev/moq/pull/4302)) + +### Other + +- fix stale agent rules, the moq-net hop range, and the ffi unannounce doc ([#4305](https://github.com/moq-dev/moq/pull/4305)) + +## [0.3.6](https://github.com/moq-dev/moq/compare/moq-net-v0.3.5...moq-net-v0.3.6) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + +### Fixed + +- *(net)* reject undeclared subscription ends ([#4231](https://github.com/moq-dev/moq/pull/4231)) +- *(net)* retain retired counters in host snapshots ([#4238](https://github.com/moq-dev/moq/pull/4238)) +- *(net)* end a track with its session's error when the session dies ([#4120](https://github.com/moq-dev/moq/pull/4120)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) +- *(moq-net)* model the dash aggregator's stats load in the session bench ([#4233](https://github.com/moq-dev/moq/pull/4233)) +- *(kio)* keep a parked waiter that quiet lists still hold ([#4240](https://github.com/moq-dev/moq/pull/4240)) +- *(moq-net)* bench lite-06, smoke-run benches nightly, refresh perf quests ([#4229](https://github.com/moq-dev/moq/pull/4229)) + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-net-v0.3.4...moq-net-v0.3.5) - 2026-09-26 ### Added @@ -112,7 +147,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [**breaking**] `create_track`, `reserve_track`, `unique_track`, `finish`, `create_group`, and `append_group` take `&self`. `track::Consumer::info()` is `query()`. `track::Demand` gains `is_used` / `poll_used` / `poll_unused`. `track::Producer::poll_unused` returns `Poll>`. `bandwidth::Producer::closed()` returns the cause. - [**breaking**] `stats::Presence` and `stats::Traffic` name both edges of each cumulative pair `*_started` / `*_ended` (`sessions_started` / `sessions_ended`, `announces_started` / `announces_ended`, `broadcasts_*`, `subscriptions_*`). Serialize still writes the previous `announced` / `*_closed` names beside the new ones; deserialize accepts either spelling, with the canonical name winning. - [**breaking**] `origin::Info` is `origin::Config` with public fields and no `with_*` builders. `Producer::info()` is `config()`. -- [**breaking**] `origin::Config::default()` mints a random hop, `Config::id` is `hop`, and origin handles expose `hop()` instead of dereferencing to `Hop`. Random hops now use the full 62-bit wire range; current `@moq/net` clients decode them as `bigint`, while legacy `@moq/lite` clients limited to `Number.MAX_SAFE_INTEGER` can reject larger values and must upgrade. +- [**breaking**] `origin::Config::default()` mints a random hop, `Config::id` is `hop`, and origin handles expose `hop()` instead of dereferencing to `Hop`. Random hops stay below 2^53, so legacy `@moq/lite` clients limited to `Number.MAX_SAFE_INTEGER` still decode them; current `@moq/net` clients decode the full 62-bit wire range as `bigint`. - [**breaking**] `origin::Producer::scope(root, patterns)` and `origin::Consumer::scope(root, patterns)` replace the separate `with_root` / `scope` calls and return `Result` with `Unauthorized` for an empty grant. - [**breaking**] `origin::Pending` is `origin::Requesting`, the consumer-side wait for a request to resolve. - `origin::Producer::publish(path, route)` creates and advertises a broadcast together. diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 7af52d3da3..967afb259d 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.5" +version = "0.3.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 4827c43e73..e6e0945c46 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -81,6 +81,55 @@ fn bench_announce(c: &mut Criterion) { group.finish(); } +/// `bench_announce` read through mounts: `publishers` routes under the fleet-wide +/// `.svc/p0`, watched by `subscribers` project sessions that each mount it at +/// their own `/.svc`. Every mount aliases the one target, the worst +/// case, so each announcement reaches every mounted cursor. Compare against +/// `origin/announce` for what a mount adds per cursor. +fn bench_announce_mounted(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/announce_mounted"); + for (publishers, subscribers) in SHAPES { + let id = BenchmarkId::from_parameter(format!("{publishers}p_{subscribers}s")); + group.bench_function(id, |b| { + let (producer, _driver) = origin::Producer::new(origin::Config::default()); + let _publishers: Vec<_> = (0..publishers) + .map(|i| { + producer + .publish(format!(".svc/p0/{i}"), origin::Route::default()) + .unwrap() + }) + .collect(); + let mut cursors: Vec = (0..subscribers) + .map(|project| { + producer + .mount(format!("p{project}/.svc"), ".svc/p0") + .unwrap() + .scope(format!("p{project}"), &Patterns::from(Pattern::all())) + .unwrap() + .consume() + .with_hidden(true) + .announced() + }) + .collect(); + for cursor in &mut cursors { + while cursor.next().now_or_never().flatten().is_some() {} + } + + b.iter(|| { + let handle = producer.publish(".svc/p0/incoming", origin::Route::default()).unwrap(); + for cursor in &mut cursors { + cursor.next().now_or_never().flatten().expect("announce delivered"); + } + drop(handle); + for cursor in &mut cursors { + cursor.next().now_or_never().flatten().expect("retract delivered"); + } + }); + }); + } + group.finish(); +} + /// A relay's own stats fan-out: `.stats//node/` for every project /// on every node, watched by one cursor per peer, each scoped to one project. /// Cursors differ in scope, so none can be collapsed, and an announcement under @@ -377,6 +426,7 @@ fn bench_handoff(c: &mut Criterion) { criterion_group!( benches, bench_announce, + bench_announce_mounted, bench_announce_fleet, bench_announce_duplicate, bench_announce_fronts, diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index 62485be85b..77df70de90 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -637,6 +637,61 @@ fn allocations() { } } +/// Withdraw a batch across a full mesh, sweeping both peers and prefixes. +/// Paused protocol time lets the quiet-point timeout drain pending work without +/// including a real wait in the measured duration. +fn withdrawal(c: &mut Criterion) { + let mut group = c.benchmark_group("session_withdrawal"); + group.sample_size(10); + for relays in [4, 8, 16] { + for broadcasts in [1, 32] { + group.bench_function(format!("{relays}r_{broadcasts}b"), |b| { + b.iter_custom(|iters| { + let mut elapsed = Duration::ZERO; + for _ in 0..iters { + let rt = runtime(); + elapsed += rt.block_on(async { + tokio::time::pause(); + let shape = Shape { + relays, + publishers: 0, + ..Shape::BASE + }; + let cluster = Cluster::new("moq-lite-06", shape).await; + let mut announced = cluster.relays.last().unwrap().consume().announced(); + let live: Vec<_> = (0..broadcasts) + .map(|i| { + cluster.relays[i % (relays - 1)] + .publish(path(i), Default::default()) + .unwrap() + }) + .collect(); + while tokio::time::timeout(Duration::from_secs(1), announced.next()) + .await + .is_ok() + {} + let start = Instant::now(); + drop(live); + let mut retracted = 0; + while let Ok(Some(event)) = + tokio::time::timeout(Duration::from_secs(1), announced.next()).await + { + assert!(matches!(event, moq_net::announce::Event::End(_)), "{event:?}"); + retracted += 1; + } + let elapsed = start.elapsed(); + assert_eq!(retracted, broadcasts); + elapsed + }); + } + elapsed + }); + }); + } + } + group.finish(); +} + fn session(c: &mut Criterion) { if std::env::var_os("SESSION_ALLOCS").is_some() { allocations(); @@ -754,5 +809,5 @@ fn session(c: &mut Criterion) { ); } -criterion_group!(benches, session); +criterion_group!(benches, session, withdrawal); criterion_main!(benches); diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 6e77a8bc17..f97355e820 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -31,6 +31,7 @@ pub mod solicit; mod subscribe; mod subscribe_namespace; mod subscriber; +pub(crate) mod token; mod track; mod version; diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index 2e4bc9440d..7a5b24164b 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -124,6 +124,8 @@ use super::Version; pub(crate) enum PublishDoneStatus { /// An implementation-specific failure ended the subscription. InternalError, + /// The subscriber is no longer authorized for the track. + Unauthorized, /// The track is no longer being published. TrackEnded, } @@ -144,6 +146,7 @@ impl PublishDoneStatus { | Version::Draft21 | Version::Draft22 => match self { Self::InternalError => 0x0, + Self::Unauthorized => 0x1, Self::TrackEnded => 0x2, }, } @@ -164,6 +167,7 @@ impl PublishDone<'_> { pub(crate) fn end(&self, version: Version) -> Result<(), crate::Error> { match self.status_code { code if code == PublishDoneStatus::TrackEnded.code(version) => Ok(()), + code if code == PublishDoneStatus::Unauthorized.code(version) => Err(crate::Error::Unauthorized), // SUBSCRIPTION_ENDED: the subscription reached the end its filter asked for. // Draft-20 removed it and left 0x3 unassigned. 0x3 if matches!( @@ -731,6 +735,10 @@ mod tests { for version in [Version::Draft14, Version::Draft19, Version::Draft20, Version::Draft22] { assert!(done(0x2).end(version).is_ok(), "{version:?}"); + assert!( + matches!(done(0x1).end(version), Err(crate::Error::Unauthorized)), + "{version:?}" + ); assert!( matches!(done(0x0).end(version), Err(crate::Error::Remote(0x0))), "{version:?}" diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index e864368dea..66e18e069a 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1779,10 +1779,16 @@ where // erroring, which would look fatal to the peer. // The wire prefix decodes as a literal path; convert it explicitly to its // subtree grant, refusing anything that cannot be a subtree. + // The cursor is rooted at the prefix so every update arrives named as its + // wire suffix, a route covering the prefix included: that one presents at + // the root, as the empty suffix. let scope = crate::Pattern::subtree(prefix.as_str()) - .map(crate::Patterns::from) + .map(|subtree| subtree.rebase(prefix.as_str())) .unwrap_or_default(); - let origin = self.origin.scope("", &scope).unwrap_or_else(|_| self.origin.empty()); + let origin = self + .origin + .scope(&prefix, &scope) + .unwrap_or_else(|_| self.origin.empty()); // The extension changes what an advertisement carries, so nothing can be // sent until the peer's SETUP says whether it speaks it. The same SETUP says @@ -2009,11 +2015,8 @@ where update: crate::announce::Announce, active: bool, ) -> Result<(), Error> { - let path = update.prefix; - let suffix = path - .strip_prefix(prefix) - .expect("origin returned invalid prefix") - .to_owned(); + let suffix = update.prefix; + let path = prefix.join(&suffix); if active { // A repeat for a live suffix is a metadata update: keep the diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 849d2f5b46..6243fc339f 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -438,6 +438,9 @@ pub struct PeerSetup { /// The request path the peer advertised, for URL-less transports. pub path: Option, + /// The credential the peer presented in its `AUTHORIZATION TOKEN` option. + pub token: Option, + /// The Setup Options it declared (see [`cluster`] and [`solicit`]). pub declared: peer::Peer, } @@ -488,11 +491,13 @@ pub async fn accept_setup( ), None => None, }; + let token = super::token::from_setup(¶ms, version)?; let declared = peer_from_params(¶ms, version)?; return Ok(PeerSetup { stream: reader, path, + token, declared, }); } diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs new file mode 100644 index 0000000000..129e9e17e4 --- /dev/null +++ b/rs/moq-net/src/ietf/token.rs @@ -0,0 +1,233 @@ +//! The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). +//! +//! The value is the Token structure of section 8.9: an Alias Type, then fields that +//! type selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means +//! no alias is ever registered and every token arrives by value. + +use crate::{ + Error, SessionError, + coding::{Decode, Encode, EncodeError}, + setup::Token, +}; + +use super::{ParameterBytes, Parameters, Version}; + +/// Retire a registered alias. +const DELETE: u64 = 0x0; +/// Register an alias for this type and value, then use them. +const REGISTER: u64 = 0x1; +/// Use the type and value a registered alias names. +const USE_ALIAS: u64 = 0x2; +/// Use the type and value carried inline. +const USE_VALUE: u64 = 0x3; + +/// The token the peer's SETUP presented, if any. +/// +/// A second token is already refused as a duplicate option by [`Parameters`]: one +/// credential per connection. +pub fn from_setup(params: &Parameters, version: Version) -> Result, Error> { + params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(|value| decode(value, version)) + .transpose() +} + +/// Present `token` in our SETUP, by value. +#[cfg_attr(not(test), expect(dead_code))] +pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version)?; + token.kind.encode(&mut value, version)?; + value.extend_from_slice(&token.value); + params.set_bytes(ParameterBytes::AuthorizationToken, value); + Ok(()) +} + +/// Decode a Token structure, refusing what a SETUP cannot carry. +fn decode(mut buf: &[u8], version: Version) -> Result { + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + let malformed = |_| Error::Session(SessionError::KeyValueFormatting); + + match u64::decode(&mut buf, version).map_err(malformed)? { + USE_VALUE => {} + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + REGISTER => { + u64::decode(&mut buf, version).map_err(malformed)?; + } + // Section 9.1.4: nothing can have been registered before SETUP. + DELETE | USE_ALIAS => return Err(Error::ProtocolViolation), + _ => return Err(Error::Session(SessionError::KeyValueFormatting)), + } + + let kind = u64::decode(&mut buf, version).map_err(malformed)?; + Ok(Token { + kind, + value: buf.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSIONS: [Version; 9] = [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ]; + + fn token() -> Token { + // A kind past one varint byte and a value that is not text, so neither is mistaken + // for the other and no codec can get away with assuming UTF-8. + Token { + kind: 300, + value: vec![0x00, 0xff, 0x03, 0x80, b'j'], + } + } + + /// The option as it arrives, after a trip through the SETUP parameter block. + fn received(params: &Parameters, version: Version) -> Parameters { + let mut bytes = params.encode_bytes(version).unwrap(); + Parameters::decode(&mut bytes, version).unwrap() + } + + /// A raw Token structure: the alias type then its varint fields then a value. + fn structure(version: Version, fields: &[u64], value: &[u8]) -> Parameters { + let mut raw = Vec::new(); + for field in fields { + field.encode(&mut raw, version).unwrap(); + } + raw.extend_from_slice(value); + let mut params = Parameters::default(); + params.set_bytes(ParameterBytes::AuthorizationToken, raw); + received(¶ms, version) + } + + #[test] + fn use_value_round_trips_on_every_draft() { + for version in VERSIONS { + let mut params = Parameters::default(); + into_setup(&mut params, &token(), version).unwrap(); + let params = received(¶ms, version); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// The same bytes `js/net/src/ietf/token.test.ts` asserts, so the two agree on the wire. + #[test] + fn the_encoding_matches_the_cross_language_vector() { + let token = Token { + kind: 300, + value: vec![0x00, 0xff], + }; + for (version, expected) in [ + (Version::Draft14, [0x03, 0x41, 0x2c, 0x00, 0xff]), + (Version::Draft17, [0x03, 0x81, 0x2c, 0x00, 0xff]), + ] { + let mut params = Parameters::default(); + into_setup(&mut params, &token, version).unwrap(); + assert_eq!( + params.get_bytes(ParameterBytes::AuthorizationToken), + Some(&expected[..]), + "{version:?}" + ); + } + } + + #[test] + fn an_empty_value_is_a_token() { + for version in VERSIONS { + let params = structure(version, &[USE_VALUE, Token::OUT_OF_BAND], &[]); + let expected = Token { + kind: Token::OUT_OF_BAND, + value: Vec::new(), + }; + assert_eq!(from_setup(¶ms, version).unwrap(), Some(expected), "{version:?}"); + } + } + + #[test] + fn absent_is_none() { + for version in VERSIONS { + assert_eq!(from_setup(&Parameters::default(), version).unwrap(), None); + } + } + + /// We advertise no cache, so a registration is the draft's own USE_VALUE. + #[test] + fn register_is_a_value() { + for version in VERSIONS { + let params = structure(version, &[REGISTER, 7, token().kind], &token().value); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// One credential per connection: a second token is refused, not unioned or dropped. + #[test] + fn two_tokens_are_refused() { + for version in VERSIONS { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version).unwrap(); + Token::OUT_OF_BAND.encode(&mut value, version).unwrap(); + + let key = u64::from(ParameterBytes::AuthorizationToken); + let (count, keys): (Option, [u64; 2]) = match version { + Version::Draft14 | Version::Draft15 => (Some(2), [key, key]), + // Delta-encoded from draft-16, so the repeat is a delta of zero. + Version::Draft16 => (Some(2), [key, 0]), + _ => (None, [key, 0]), + }; + let mut raw = Vec::new(); + if let Some(count) = count { + count.encode(&mut raw, version).unwrap(); + } + for key in keys { + key.encode(&mut raw, version).unwrap(); + value.encode(&mut raw, version).unwrap(); + } + + let err = Parameters::decode(&mut raw.as_slice(), version).unwrap_err(); + assert!(matches!(err, crate::DecodeError::Duplicate), "{version:?}: {err:?}"); + } + } + + #[test] + fn an_alias_reference_is_a_protocol_violation() { + for version in VERSIONS { + for alias_type in [DELETE, USE_ALIAS] { + let params = structure(version, &[alias_type, 7], &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::ProtocolViolation), + "{version:?} {alias_type}: {err:?}" + ); + } + } + } + + #[test] + fn an_undecodable_structure_is_a_formatting_error() { + for version in VERSIONS { + for (fields, why) in [ + (&[][..], "no alias type"), + (&[USE_VALUE][..], "no token type"), + (&[REGISTER][..], "no alias"), + (&[REGISTER, 7][..], "no token type after the alias"), + (&[0x4, 0][..], "an unknown alias type"), + ] { + let params = structure(version, fields, &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::Session(SessionError::KeyValueFormatting)), + "{version:?} {why}: {err:?}" + ); + } + } + } +} diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 732ce41989..ffb5a32415 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -85,7 +85,7 @@ mod lite; mod model; pub mod path; mod recv; -mod setup; +pub mod setup; mod tail; #[cfg(test)] mod test_interop; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 5dd16714de..5ce1eaefc8 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1,7 +1,7 @@ use crate::runtime::Timers as _; use crate::{SessionError, announce, frame, group, origin, track}; use std::{ - collections::HashMap, + collections::{BTreeSet, HashMap}, ops::Bound, sync::{ Arc, @@ -203,11 +203,10 @@ impl Publisher { stream: &mut Stream, origin: &origin::Consumer, announced: &mut announce::Consumer, - prefix: impl crate::AsPath, self_origin: Hop, version: Version, ) -> Result<(), Error> { - let mut run = AnnounceRun::new(prefix.as_path().to_owned(), self_origin, version); + let mut run = AnnounceRun::new(self_origin, version); kio::wait(|waiter| run.poll(stream, origin, announced, waiter)).await } } @@ -529,10 +528,10 @@ impl AnnounceServe { | Error::Stream(crate::StreamError::Cancel) | Error::Session(crate::SessionError::Cancel) | Error::Transport(_) => { - tracing::debug!(prefix = %origin.absolute(&run.prefix), "announcing cancelled"); + tracing::debug!(prefix = %origin.absolute(""), "announcing cancelled"); } err => { - tracing::warn!(%err, prefix = %origin.absolute(&run.prefix), "announcing error"); + tracing::warn!(%err, prefix = %origin.absolute(""), "announcing error"); } } self.stream.take().expect("stream present").writer.abort(&err); @@ -550,13 +549,16 @@ impl AnnounceServe { // fatal stream close), rather than erroring, which would reset the stream. // The wire prefix decodes as a literal path; convert it explicitly to its // subtree grant, refusing anything that cannot be a subtree. + // The cursor is rooted at the prefix so every update arrives named as its + // wire suffix, a route covering the prefix included: that one presents at + // the root, as the empty suffix. let scope = crate::Pattern::subtree(prefix.as_str()) - .map(crate::Patterns::from) + .map(|subtree| subtree.rebase(prefix.as_str())) .unwrap_or_default(); let origin = self .shared .origin - .scope("", &scope) + .scope(&prefix, &scope) .unwrap_or_else(|_| self.shared.origin.empty()); // Register the split-horizon peer on the announce cursor too. The origin // model uses this exposure to park a reflected copy before it can replace @@ -569,7 +571,7 @@ impl AnnounceServe { false => origin, }; let announced = origin.announced(); - let run = AnnounceRun::new(prefix, self.shared.self_origin, self.shared.version); + let run = AnnounceRun::new(self.shared.self_origin, self.shared.version); self.state = AnnounceState::Run { origin, announced, run }; } } @@ -577,7 +579,6 @@ impl AnnounceServe { /// The announce loop's state, minus the handles it borrows per poll so the test /// shim can supply its own. struct AnnounceRun { - prefix: crate::PathOwned, self_origin: Hop, version: Version, // Lite06+: announce ids. Every `active` we send implicitly assigns the next @@ -600,9 +601,8 @@ enum AnnouncePhase { } impl AnnounceRun { - fn new(prefix: crate::PathOwned, self_origin: Hop, version: Version) -> Self { + fn new(self_origin: Hop, version: Version) -> Self { Self { - prefix, self_origin, version, encoder: lite::AnnounceEncoder::new(version), @@ -611,16 +611,6 @@ impl AnnounceRun { } } - /// Where an update travels on this stream: its prefix relative to the requested - /// prefix, which the origin's scope guarantees it sits under. - fn suffix(&self, update: &announce::Announce) -> crate::PathOwned { - update - .prefix - .strip_prefix(&self.prefix) - .expect("origin returned a route outside the requested prefix") - .to_owned() - } - /// The chain and cost to put on the wire for `route`, or `None` when it must /// not be forwarded. fn outgoing(&self, route: &crate::origin::Route, absolute: &crate::Path) -> Option<(Hops, crate::origin::Cost)> { @@ -717,7 +707,7 @@ impl AnnounceRun { announce::Event::Live => continue, }; let absolute = origin.absolute(&update.prefix); - let suffix = self.suffix(&update); + let suffix = update.prefix; if active { if self.outgoing(&update.route, &absolute).is_none() { @@ -752,7 +742,7 @@ impl AnnounceRun { announce::Event::Live => continue, }; let absolute = origin.absolute(&update.prefix); - let suffix = self.suffix(&update); + let suffix = update.prefix; if active { let Some((hops, cost)) = self.outgoing(&update.route, &absolute) else { @@ -832,7 +822,7 @@ impl AnnounceRun { }; let absolute = origin.absolute(&update.prefix); - let suffix = self.suffix(&update); + let suffix = update.prefix; if !active { self.retract(stream, suffix, &absolute)?; @@ -1775,7 +1765,7 @@ mod announce_test { let task = tokio::spawn(async move { let mut announced = consumer.announced(); let self_origin = consumer.hop(); - TestPublisher::run_announce(&mut stream, &consumer, &mut announced, "", self_origin, VERSION).await + TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, VERSION).await }); settle().await; @@ -1879,7 +1869,7 @@ mod announce_test { let task = tokio::spawn(async move { let mut announced = consumer.announced(); let self_origin = consumer.hop(); - TestPublisher::run_announce(&mut stream, &consumer, &mut announced, "", self_origin, VERSION).await + TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, VERSION).await }); settle().await; @@ -2245,6 +2235,10 @@ struct TrackRun { // exclusive final sequence (which may be ahead of the live edge). emit_range: bool, start_sent: bool, + // The first servable group, held until the source resolves where its feed starts. + first: Option, + // Groups skipped for a missing head before the start resolved, which it must not name. + skipped: BTreeSet, end_sent: bool, // Lite07+ sends SUBSCRIBE_END with the stream count instead of as soon as the // boundary is known, once every group below it has opened its stream. @@ -2281,6 +2275,8 @@ impl TrackRun { track_priority_tx, emit_range, start_sent: false, + first: None, + skipped: BTreeSet::new(), end_sent: false, count_streams, datagrams, @@ -2331,6 +2327,27 @@ impl TrackRun { continue; } + // The first group waits for the source to resolve where its feed starts; datagrams + // keep flowing meanwhile. + if let Some(group) = self.first.take() { + match self.track.poll_start(waiter) { + Poll::Ready(source) => { + self.start(group, source, stream)?; + continue; + } + Poll::Pending => { + self.first = Some(group); + if self.datagrams + && let Poll::Ready(Some(datagram)) = self.track.poll_recv_datagram(waiter)? + { + self.ctx.serve_datagram(datagram); + continue; + } + return Poll::Pending; + } + } + } + // One cursor drives the whole subscription: poll the cap-aware arrival-order // group and, when enabled, the next best-effort datagram. Groups are polled // first so a datagram burst can't starve them; datagrams flow whenever no @@ -2339,45 +2356,20 @@ impl TrackRun { if let Poll::Ready(res) = poll_recv_next(&mut self.track, self.datagrams, emit_boundary, waiter) { match res? { Recv::Group(mut group) => { - let sequence = group.sequence; if !position_group(&mut group, self.start_frame, self.end_frame) { // Its head is gone, and this subscriber didn't ask for a // partial group. Skip it rather than open a stream that can // only be reset; the next servable group resolves the start. - tracing::debug!(subscribe = self.ctx.id, track = %self.ctx.track_name, sequence, "skipping group with a missing head"); + tracing::debug!(subscribe = self.ctx.id, track = %self.ctx.track_name, sequence = group.sequence, "skipping group with a missing head"); + if self.emit_range && !self.start_sent { + self.skipped.insert(group.sequence); + } continue; } - if self.emit_range && !self.start_sent { - self.start_sent = true; - // Only the group: the subscriber derives the start frame from - // its own request (see `lite::SubscribeStart`). - stream - .writer - .buffer(&lite::SubscribeResponse::Start(lite::SubscribeStart { - group: sequence, - }))?; - // SUBSCRIBE_OK is an implicit drop of everything below the - // resolved start (the subscriber records it as a permanent - // miss), so a lower group arriving late must not be served - // after all. A widening SUBSCRIBE_UPDATE re-lowers the floor, - // renegotiating the resolved start along with the demand. - self.track.start_at(sequence); + match self.emit_range && !self.start_sent { + true => self.first = Some(group), + false => self.serve(group), } - - let frame_start = group.index(); - tracing::debug!(subscribe = self.ctx.id, track = %self.ctx.track_name, sequence, "serving group"); - - // Use the latest priority for new groups so SUBSCRIBE_UPDATE applies to them too. - let current_priority = self.ctx.track_priority_current(); - // The subscribe id scopes the group tie-break: one queue serves every - // subscription on the session, and only groups of the same one may be - // ranked against each other by sequence. - let handle = self - .ctx - .priority - .insert(Priority::new(current_priority, self.ctx.id, sequence)); - self.children - .push(GroupServe::new(self.ctx.clone(), sequence, frame_start, handle, group)); } Recv::Datagram(datagram) => self.ctx.serve_datagram(datagram), Recv::Boundary(group) => { @@ -2414,6 +2406,63 @@ impl TrackRun { } } +impl TrackRun { + /// Send SUBSCRIBE_START for the first servable group, then serve it. + /// + /// A relay caches groups in upstream arrival order, and a newer group's stream can + /// beat an older one, so the first group here need not be the oldest the source + /// serves. `source` is where the source's feed starts, raised to this cursor's floor, + /// so the resolved start is the lower of the two: resolving from the later group would + /// drop the older one for good. + fn start( + &mut self, + group: group::Consumer, + source: Option, + stream: &mut Stream, + ) -> Result<(), Error> { + let mut start = source.map_or(group.sequence, |source| source.min(group.sequence)); + // A skipped group is never served, so it cannot be where the feed starts. This stops + // at the held group at the latest, since it was not skipped. + while self.skipped.contains(&start) { + start += 1; + } + self.skipped.clear(); + self.start_sent = true; + // Only the group: the subscriber derives the start frame from its own request + // (see `lite::SubscribeStart`). + stream + .writer + .buffer(&lite::SubscribeResponse::Start(lite::SubscribeStart { group: start }))?; + // SUBSCRIBE_START is an implicit drop of everything below the resolved start (the + // subscriber records it as a permanent miss), so a lower group arriving late must + // not be served after all. A widening SUBSCRIBE_UPDATE re-lowers the floor, + // renegotiating the resolved start along with the demand. Raised, not assigned: an + // update that landed while the group was held may already have raised it past. + self.track.raise_start_to(start); + self.serve(group); + Ok(()) + } + + /// Open a group machine for `group`. + fn serve(&mut self, group: group::Consumer) { + let sequence = group.sequence; + let frame_start = group.index(); + tracing::debug!(subscribe = self.ctx.id, track = %self.ctx.track_name, sequence, "serving group"); + + // Use the latest priority for new groups so SUBSCRIBE_UPDATE applies to them too. + let current_priority = self.ctx.track_priority_current(); + // The subscribe id scopes the group tie-break: one queue serves every + // subscription on the session, and only groups of the same one may be + // ranked against each other by sequence. + let handle = self + .ctx + .priority + .insert(Priority::new(current_priority, self.ctx.id, sequence)); + self.children + .push(GroupServe::new(self.ctx.clone(), sequence, frame_start, handle, group)); + } +} + /// Serves one group on its own unidirectional stream: the header, then every /// frame, applying queue and SUBSCRIBE_UPDATE priority changes as they land. struct GroupServe { @@ -3135,6 +3184,155 @@ mod serve_group_test { assert_eq!(*log.writes.lock().unwrap(), [0, 1, 0, 1, 2, 3, 2]); } + /// A lite-07 run over a relay's track, whose upstream subscription still waits on the + /// source's SUBSCRIBE_START, on a subscribe stream the test can push updates onto. + struct RelayRun { + run: TrackRun, + stream: Stream, + session: ScriptedSession, + opens: Arc, + } + + impl RelayRun { + /// Subscribe to `track` from `start_group`. + async fn new(track: &mut track::Producer, start_group: u64) -> Self { + track.request_start(Some(0)).unwrap(); + let subscription = track::Subscription::default() + .with_start(track::Position::group(start_group)) + .with_max_age(Duration::from_secs(30)); + let subscriber = track.subscribe(subscription); + + let mut session = ScriptedSession::new(Vec::new()); + let (send, recv) = futures::future::poll_fn(|cx| { + ::poll_open_bi(&mut session, cx) + }) + .await + .unwrap(); + let stream = Stream:: { + writer: Writer::new(send, Version::Lite07), + reader: crate::coding::Reader::new(recv, Version::Lite07), + }; + let track_priority = kio::Producer::new(0u8); + let opens = Arc::::default(); + let ctx = Subscription { + session: session.clone(), + id: 0, + track_name: "test".into(), + priority: PriorityQueue::default(), + track_priority: track_priority.consume(), + track_priority_seen: 0, + version: Version::Lite07, + timescale: Some(crate::Timescale::default()), + opens: opens.clone(), + }; + let bounds = Bounds { + start_group: Some(start_group), + start_frame: 0, + end_group: None, + end_frame: None, + }; + Self { + run: TrackRun::new(ctx, subscriber, bounds, track_priority), + stream, + session, + opens, + } + } + + /// Drive the run until it parks, which it must. + fn settle(&mut self) { + let Self { run, stream, .. } = self; + let res = kio::wait(|waiter| run.poll(stream, waiter)).now_or_never(); + assert!(res.is_none(), "the run ended"); + } + + /// How many group streams the run opened. + fn opened(&self) -> u64 { + self.opens.opened.load(Ordering::Relaxed) + } + + /// Whether the first thing written was SUBSCRIBE_START at `group`. + fn started_at(&self, group: u64) -> bool { + let start = lite::SubscribeResponse::Start(lite::SubscribeStart { group }); + let start = start.encode_bytes(Version::Lite07).unwrap(); + self.session.log.writes.lock().unwrap().starts_with(&start) + } + } + + /// A SUBSCRIBE_UPDATE landing while the first group waits on the source's start keeps + /// the floor it raised: resolving the start from the held group must not lower it. + #[tokio::test] + async fn held_first_group_keeps_an_updated_floor() { + let mut track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); + let mut relay = RelayRun::new(&mut track, 0).await; + + write_group(&mut track, 5, 0); + relay.settle(); + + // The subscriber moves its start past the held group before the source resolves. + let update = lite::SubscribeUpdate { + priority: 0, + max_age: Duration::ZERO, + start_group: Some(7), + end_group: None, + start_frame: 0, + end_frame: None, + }; + relay.session.push(&update.encode_bytes(Version::Lite07).unwrap()); + relay.settle(); + + track.start_at(0).unwrap(); + relay.settle(); + assert_eq!(relay.opened(), 1, "the held group is served"); + + // Group 6 is below the updated floor. + write_group(&mut track, 6, 6); + relay.settle(); + assert_eq!(relay.opened(), 1, "served a group below the floor"); + } + + /// A source whose feed starts below the subscriber's floor serves the floor's group, so + /// a newer group arriving first must not resolve the start past it. + #[tokio::test] + async fn held_first_group_resolves_to_the_floor_under_the_source() { + let mut track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); + let mut relay = RelayRun::new(&mut track, 5).await; + + write_group(&mut track, 6, 6); + relay.settle(); + track.start_at(0).unwrap(); + relay.settle(); + assert_eq!(relay.opened(), 1, "the held group is served"); + + write_group(&mut track, 5, 5); + relay.settle(); + assert_eq!(relay.opened(), 2, "dropped the floor's group"); + assert!(relay.started_at(5)); + } + + /// A group skipped for a missing head before the start resolves is never served, so + /// SUBSCRIBE_START must not name it, even where the source's feed starts. + #[tokio::test] + async fn held_first_group_starts_past_a_skipped_head() { + let mut track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); + let mut relay = RelayRun::new(&mut track, 5).await; + + // Group 5's first frame is gone. + let mut headless = track.create_group(group::Info { sequence: 5 }).unwrap(); + headless.start_at(1).unwrap(); + headless + .write_frame(Timestamp::from_millis(5).unwrap(), b"x".as_slice()) + .unwrap(); + headless.finish().unwrap(); + write_group(&mut track, 6, 6); + relay.settle(); + + track.start_at(5).unwrap(); + relay.settle(); + assert_eq!(relay.opened(), 1, "the held group is served"); + assert!(relay.started_at(6), "named the skipped group"); + } + /// A track that ends without a group still ends the subscription, with no stream owed. #[tokio::test] async fn lite07_end_counts_zero_streams() { @@ -3279,7 +3477,6 @@ mod tests { &mut stream, &consumer, &mut announced, - crate::Path::new(""), self_origin, Version::Lite01, )); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 854600586a..fa9033b837 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -219,14 +219,14 @@ impl Subscriber { lite::AnnounceBroadcast::Ended { suffix, .. } => { let path = prefix.join(&suffix); tracing::debug!(broadcast = %self.log_path(&path), "unannounced"); - run.announced.retire(&path); + run.announced.withdraw(&path); } lite::AnnounceBroadcast::EndedId { id } => { // Resolve and retire the id; an unknown or already-retired id is a // protocol violation. let path = prefix.join(&run.decoder.end(id)?); tracing::debug!(broadcast = %self.log_path(&path), "unannounced"); - run.announced.retire(&path); + run.announced.withdraw(&path); } lite::AnnounceBroadcast::Restart { id, hops, cost } => { // Resolve the id; it stays live (the replacement reuses it). An unknown @@ -2701,7 +2701,7 @@ mod tests { .unwrap(); cursor.assert_next_active("room/host"); assert!(announced.contains(&path.clone()), "the announce was not recorded"); - announced.retire(&path.clone()); + announced.withdraw(&path); cursor.assert_next_ended("room/host"); } @@ -2815,8 +2815,8 @@ struct SubStream { tail: kio::Producer, /// The first group the publisher serves (SUBSCRIBE_START), once declared. served: Option, - /// The track's exclusive end (SUBSCRIBE_END), once declared. - end: Option, + /// The track's exclusive end and stream count (SUBSCRIBE_END), once declared. + end: Option, } impl SubStream { @@ -2825,7 +2825,7 @@ impl SubStream { /// `None` when nothing says which: drafts before SUBSCRIBE_END only have the FIN. /// Without a SUBSCRIBE_START the publisher served no group at all. fn owed(&self, requested_end: Option) -> Option> { - let end = self.end?; + let end = self.end.as_ref()?.group; let end = requested_end.map_or(end, |requested| requested.min(end)); Some(self.served.unwrap_or(end)..end) } @@ -2889,9 +2889,12 @@ impl Announced { self.routes.get_mut(path)?.as_mut() } - fn retire(&mut self, path: &PathOwned) { - // Dropping the route closes its sources. - self.routes.remove(path); + /// Retire this session's advertisement without invalidating another live + /// session from the same peer. Dropping its sources closes their requests. + fn withdraw(&mut self, path: &PathOwned) { + if let Some(Some(entry)) = self.routes.remove(path) { + entry.dynamic.withdrawn(); + } } /// Serve queued requests on every ready route: mint a source per requested @@ -3602,11 +3605,13 @@ enum ServeMode { /// exactly like the old inline await. Establish(Establish), /// The upstream FIN'd, so the track is over, but QUIC does not order streams: keep - /// the subscription routable until every group it owes is accounted for (a stream's - /// header or a SUBSCRIBE_DROP), or the grace gives up on one reset before its header. + /// the subscription routable until the counted headers arrive (lite-07), or every + /// owed group is accounted for (older drafts). The grace bounds a stream reset + /// before its header arrived. Tail { settle: Settle, owed: Option>, + streams: Option, }, } @@ -3653,10 +3658,13 @@ impl ServeLoop { } } } - ServeMode::Tail { settle, owed } => { + ServeMode::Tail { settle, owed, streams } => { let _ = self.fetches.poll(waiter); if settle - .poll(waiter, |tail| owed.clone().is_some_and(|owed| tail.covers(owed))) + .poll(waiter, |tail| match streams { + Some(streams) => tail.streams() >= *streams, + None => owed.clone().is_some_and(|owed| tail.covers(owed)), + }) .is_ready() { return Poll::Ready(ServeEnd::Finished); @@ -3769,7 +3777,7 @@ impl ServeLoop { if let Err(err) = self.serving.finish_at(end.group) { tracing::warn!(track = %serve.name, group = end.group, %err, "invalid subscribe end"); } - active.end = Some(end.group); + active.end = Some(end.clone()); } // SUBSCRIBE_START names the first group this feed serves: // the publisher skipped everything below it (e.g. it could @@ -3831,6 +3839,11 @@ impl ServeLoop { self.mode = ServeMode::Tail { settle: Settle::new(&serve.subscriber.runtime, active.tail.consume(), grace), owed: active.owed(requested_end), + streams: active + .end + .as_ref() + .filter(|_| serve.subscriber.version.has_stream_count()) + .map(|end| end.streams), }; continue; } diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 0f3ac4a6c4..ca4c56735b 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -675,6 +675,12 @@ impl ScriptedSession { } } + /// Append to the shared script: the peer sending more on a stream it already opened. + /// Nothing is woken, so the test re-polls the reader itself. + pub fn push(&self, bytes: &[u8]) { + self.script.lock().unwrap().extend_from_slice(bytes); + } + /// Answer each stream from `scripts`, but only once the gate opens: a peer that /// replies normally and is simply out of stream credit until then. pub fn gated_open(scripts: Vec>, gate: kio::Consumer) -> Self { diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index c4d39408c7..2b873e8e63 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -412,6 +412,29 @@ impl Producer { } } + /// Remove the spliced track `producer` from under `name`, unless it has a reader. + /// + /// Returns false only when a reader holds it: lookups hand out readers under the + /// same lock, so one arriving after the caller decided is never cut off. Anything + /// else (removed, or already replaced under the name) is gone as far as the caller + /// is concerned. + pub(crate) fn forget_spliced(&self, name: &str, producer: &super::resume::Producer) -> bool { + let mut state = self.state.lock(); + let Some(spliced) = state.spliced.as_mut() else { + return true; + }; + match spliced.tracks.get(name) { + Some(current) if current.is_clone(producer) => { + if current.is_used() { + return false; + } + spliced.tracks.remove(name); + true + } + _ => true, + } + } + /// 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 @@ -767,11 +790,12 @@ impl Consumer { // the time, not a property of the name: a publisher that had not yet // created the track may have it now. Drop it so this request reaches a // source again, exactly as the plain lookup below reclaims a closed - // entry. A *finished* one stays, since its cache is still readable. + // entry. A *finished* one stays, since its cache is still readable, + // until the front forgets it after going unread for its linger. // - // So a name, once finished, never comes back here: a publisher that - // finishes a track and publishes it again is serving new content, not - // resuming this one, and a subscriber has to re-read the catalog and + // So a name, once finished, is never spliced onto again: a publisher + // that finishes a track and publishes it again is serving new content, + // not resuming this one, and a subscriber has to re-read the catalog and // re-initialize rather than be spliced onto it. Resuming the same // content across routes is the transparent case, and that is what // `resume::Producer` already does. Publish new content under a new diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index 6bdb7cfe23..b4f85cea3c 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -10,7 +10,7 @@ //! global eviction task. //! //! Cross-track ordering comes from one statistic: the mean last-access time of the -//! evictable population (every cached group except each track's protected latest). +//! evictable population (every cached group except each live track's protected latest). //! A group accessed more recently than that mean is never evicted, so freshly read //! or fetched content in one track can't die while another track holds staler //! content, and a track @@ -18,9 +18,10 @@ //! old entries and inserting new ones both advance the mean, so the eviction //! frontier moves with cache turnover on its own. //! -//! The pool also owns the wall-clock LRU window ([`Pool::expiry`]): a non-latest -//! group that nobody has read or written for that long is reclaimed, no matter what -//! retention its track advertises. Track retention +//! The pool also owns the wall-clock LRU window ([`Pool::expiry`]): a group that +//! nobody has read or written for that long is reclaimed, no matter what retention +//! its track advertises. Only a live track's latest group is exempt: once a track +//! ends, a stale consumer can't pin any of it. Track retention //! ([`max_age`](crate::track::Info::max_age)) is measured in media timestamps, so a //! congestion stall can't age content out; the pool's expiry is the orthogonal //! wall-clock bound that keeps unwatched content from pinning RAM. @@ -101,8 +102,8 @@ impl Config { /// Set the wall-clock LRU window. `None` disables idle reclamation. /// - /// A non-latest cached group that nobody reads or writes for this long is - /// reclaimed, surfacing to any remaining reader as + /// A cached group (other than a live track's latest) that nobody reads or writes + /// for this long is reclaimed, surfacing to any remaining reader as /// [`Error::Old`](crate::Error::Old). This is independent of track retention: /// [`max_age`](crate::track::Info::max_age) uses media timestamps, while this /// window keeps idle content from pinning memory. Reclaiming without a write behind @@ -269,7 +270,7 @@ impl Pool { /// Call after polling and at the returned deadline, including when idle. /// Calls before that deadline only advance the pool's sampled clock. A due /// pass visits every cached group, dating accesses since the last pass and - /// reclaiming idle groups except each track's latest. Delayed calls extend + /// reclaiming idle groups except each live track's latest. Delayed calls extend /// retention. Shared pools use the latest supplied instant. /// /// `None` means both cache policies are disabled. After enabling a capacity @@ -564,7 +565,14 @@ impl Track { } // Counts as a producer while it lives, which is why `track::Producer` gates // its teardown on its own clone count rather than the state's. - let Some(state) = self.state.upgrade() else { return }; + let Some(state) = self.state.upgrade() else { + // An ended track's channel is closed, but a stale consumer can still hold its + // groups: the sweep expires them in place, or they would never go. + if full && scan_expiry { + self.state.read(|state| state.expire_closed(state.expiry_scan_drain())); + } + return; + }; let expiry = if scan_expiry { let state = state.read(); let scan = if full { diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index 25ef7dd2a0..eba8ca277e 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -53,8 +53,9 @@ pub(super) enum Event { Resolved { route: u64, result: Result }, /// A source closed: it will never serve again. SourceClosed { source: u64 }, - /// The spliced broadcast handed out a new logical track to serve. - TrackAssigned { track: Arc }, + /// The spliced broadcast handed out a new logical track to serve. It has no + /// reader until [`Event::Used`] says so. + TrackAssigned { track: Arc, now: Instant }, /// A source answered a track query: its copy's metadata, or a refusal. /// `closing` is whether the source has begun closing, in which case a /// refusal is not held against it: it is on its way out. @@ -80,6 +81,9 @@ pub(super) enum Event { Unused { track: Arc, now: Instant }, /// The armed deadline passed. Deadline { now: Instant }, + /// The driver let go of a track the machine asked it to [`Action::Forget`]. + /// Not fed when a reader arrived first: [`Event::Used`] follows instead. + Forgotten { track: Arc }, /// The origin is tearing down. Closed, } @@ -105,9 +109,10 @@ pub(super) enum Action { /// Drop the source copy of `track` but keep the delivered groups spliced, /// so resume stays seamless while nobody reads. Park { track: Arc }, - /// Drop the source copy of `track` and every delivered group: the linger expired - /// unread, or the source is local and keeps its own cache. - Release { track: Arc }, + /// Remove `track` from the broadcast and drop everything behind it, unless a reader + /// arrived meanwhile: it went unread for the linger, or the source is local and + /// keeps its own cache. Feed back [`Event::Forgotten`] once it is gone. + Forget { track: Arc }, /// The logical track completed. Finish { track: Arc }, /// The logical track failed for good. @@ -174,8 +179,8 @@ enum TrackState { Querying { source: u64 }, /// A source's copy is spliced in and being served. Spliced { source: u64 }, - /// Nobody reads it: the copy was dropped, the delivered groups stay - /// spliced until the linger expires. + /// Nobody reads it: the copy was dropped, and whatever the track delivered + /// stays spliced until the linger expires and the track is forgotten. Parked { since: Instant }, } @@ -189,6 +194,9 @@ struct Track { refusal: Option, /// Whether anyone reads the track: a copy is only spliced in for a reader. used: bool, + /// The track finished or aborted: nothing is spliced again, and it stays only + /// until it goes unread for the linger. + ended: bool, } /// One front's state; see the module docs. @@ -202,7 +210,8 @@ pub(super) struct Front { serving_closing: bool, /// The route an upstream request is in flight through. upstream: Option, - /// Routes that refused the path while another source was serving. + /// Routes excluded from selection: they refused the path while another + /// source was serving, or their source ended while still advertised. refused: HashSet, /// Why the last candidate fell through, reported if the front ends unresolved. last_err: Option, @@ -212,7 +221,7 @@ pub(super) struct Front { /// Immutable track metadata, fixed by the first copy of each track. Every /// source of the broadcast must serve the same content. info: BTreeMap, track::Info>, - /// How long an unread track keeps its delivered groups spliced. + /// How long an unread track stays before it is forgotten. linger: Duration, /// The deadline last armed, so a step only re-arms on change. armed: Option, @@ -221,7 +230,7 @@ pub(super) struct Front { impl Front { /// A front with no source and no tracks; `linger` is how long an unread - /// track keeps its delivered groups spliced. + /// track stays before it is forgotten. pub(super) fn new(linger: Duration) -> Self { Self { identity: Identity::Undetermined, @@ -244,7 +253,7 @@ impl Front { self.identity.pin() } - /// The routes that refused the path; the driver skips them when selecting. + /// The routes excluded from selection; the driver skips them. pub(super) fn refused_routes(&self) -> &HashSet { &self.refused } @@ -277,15 +286,17 @@ impl Front { Event::SourceClosed { source } => self.source_closed(source, &mut actions), // A fresh logical track, even under a name served before: an earlier // verdict belonged to that request, and a later one asks afresh. The - // broadcast's track metadata is what persists. - Event::TrackAssigned { track } => { + // broadcast's track metadata is what persists. Unread until a reader + // shows up, so a lookup whose reader left early is forgotten too. + Event::TrackAssigned { track, now } => { self.tracks.insert( track, Track { - state: TrackState::Idle, + state: TrackState::Parked { since: now }, refused: HashSet::new(), refusal: None, used: false, + ended: false, }, ); } @@ -305,6 +316,9 @@ impl Front { Event::Used { track } => self.used(track, &mut actions), Event::Unused { track, now } => self.unused(track, now, &mut actions), Event::Deadline { now } => self.deadline(now, &mut actions), + Event::Forgotten { track } => { + self.tracks.remove(&track); + } Event::Closed => self.end(Error::Dropped, &mut actions), } if !self.ended { @@ -405,7 +419,7 @@ impl Front { } // A read track is never parked, so idle is the only state to re-query. for (name, track) in &mut self.tracks { - if track.used && track.state == TrackState::Idle { + if track.used && !track.ended && track.state == TrackState::Idle { track.state = TrackState::Querying { source }; actions.push(Action::Query { track: name.clone(), @@ -429,12 +443,16 @@ impl Front { } fn source_closed(&mut self, source: u64, actions: &mut Vec) { - let Some((serving, _)) = self.serving else { + let Some((serving, route)) = self.serving else { return; }; if serving != source { return; } + // A standing route can outlive the source it produced. Asking it again + // would re-request the broadcast that just ended; another route to the + // same publisher may still resume it. + self.refused.insert(route); self.serving = None; self.serving_closing = false; actions.push(Action::Detach { source }); @@ -514,11 +532,11 @@ impl Front { return; } match result { - // Over for good: the track leaves the machine, so a name served again - // later starts afresh and a long-lived front does not keep a stub per - // name it ever served. + // Over for good: readers drain what it delivered, and it is forgotten + // once unread for the linger, like any other track. Ok(()) => { - self.tracks.remove(&name); + track.state = TrackState::Idle; + track.ended = true; actions.push(Action::Finish { track: name }); } // Died mid-serve after delivering: normal failover, re-splice from @@ -555,12 +573,12 @@ impl Front { return; }; let track = self.tracks.get_mut(&name).expect("dispatching a known track"); - if !track.used || track.state != TrackState::Idle { + if !track.used || track.ended || track.state != TrackState::Idle { return; } if track.refused.contains(&source) { let err = track.refusal.clone().unwrap_or(Error::NotFound); - self.tracks.remove(&name); + track.ended = true; actions.push(Action::Abort { track: name, err }); return; } @@ -589,11 +607,11 @@ impl Front { track.used = false; match track.state { // A local source keeps its own cache, so a warm copy would only be a staler - // duplicate of it: drop the copy outright, and a returning reader re-splices - // the source and reads its cache against the real live edge. + // duplicate of it: forget the track outright, and a returning reader + // re-splices the source and reads its cache against the real live edge. TrackState::Spliced { .. } if self.identity == Identity::Local => { track.state = TrackState::Idle; - actions.push(Action::Release { track: name }); + actions.push(Action::Forget { track: name }); } // Drop the copy so the source goes idle at once; the delivered // groups stay spliced for the linger. @@ -601,21 +619,22 @@ impl Front { track.state = TrackState::Parked { since: now }; actions.push(Action::Park { track: name }); } - TrackState::Querying { .. } => track.state = TrackState::Idle, - _ => {} + TrackState::Parked { .. } => {} + TrackState::Idle | TrackState::Querying { .. } => track.state = TrackState::Parked { since: now }, } } fn deadline(&mut self, now: Instant, actions: &mut Vec) { // The driver's deadline fired and cleared itself: re-arm whatever is still - // parked, even if nothing was due yet, or it would never be released. + // parked, even if nothing was due yet, or it would never be forgotten. + // Idle until the driver confirms: a reader that arrived first keeps it. self.armed = None; for (name, track) in &mut self.tracks { if let TrackState::Parked { since } = track.state && since + self.linger <= now { track.state = TrackState::Idle; - actions.push(Action::Release { track: name.clone() }); + actions.push(Action::Forget { track: name.clone() }); } } } @@ -700,13 +719,23 @@ mod tests { }), &[Action::Resolve], ); - assert_actions(front.step(Event::TrackAssigned { track: name("video") }), &[]); + let t0 = Instant::now(); assert_actions( - front.step(Event::Used { track: name("video") }), - &[Action::Query { + front.step(Event::TrackAssigned { track: name("video"), - source, - }], + now: t0, + }), + &[Action::Arm { at: Some(t0 + LINGER) }], + ); + assert_actions( + front.step(Event::Used { track: name("video") }), + &[ + Action::Query { + track: name("video"), + source, + }, + Action::Arm { at: None }, + ], ); assert_actions( front.step(Event::TrackInfo { @@ -813,6 +842,7 @@ mod tests { front.step(Event::SourceClosed { source: 100 }), &[Action::Detach { source: 100 }, Action::Reselect], ); + assert!(front.refused_routes().contains(&1)); assert_actions( front.step(Event::Selected { best: Some(remote(3, 10)), @@ -964,7 +994,10 @@ mod tests { #[test] fn a_source_refusing_a_track_aborts_it_and_nothing_else() { let mut front = serving(remote(1, 10), 100); - front.step(Event::TrackAssigned { track: name("audio") }); + front.step(Event::TrackAssigned { + track: name("audio"), + now: Instant::now(), + }); front.step(Event::Used { track: name("audio") }); assert_actions( front.step(Event::TrackInfo { @@ -984,7 +1017,10 @@ mod tests { #[test] fn a_closing_source_refusal_is_not_a_verdict() { let mut front = serving(remote(1, 10), 100); - front.step(Event::TrackAssigned { track: name("audio") }); + front.step(Event::TrackAssigned { + track: name("audio"), + now: Instant::now(), + }); front.step(Event::Used { track: name("audio") }); assert_actions( front.step(Event::TrackInfo { @@ -1094,7 +1130,7 @@ mod tests { } #[test] - fn unread_track_parks_then_releases_after_the_linger() { + fn unread_track_parks_then_is_forgotten_after_the_linger() { let mut front = serving(remote(1, 10), 100); let t0 = Instant::now(); assert_actions( @@ -1117,9 +1153,26 @@ mod tests { // nothing re-arms. assert_actions( front.step(Event::Deadline { now: t0 + LINGER }), - &[Action::Release { track: name("video") }], + &[Action::Forget { track: name("video") }], + ); + assert_actions(front.step(Event::Forgotten { track: name("video") }), &[]); + assert!(!front.tracks.contains_key(&name("video"))); + } + + /// A reader that looked the track up before the driver could forget it keeps + /// it: the driver feeds `Used` instead of `Forgotten`, and the track re-splices. + #[test] + fn a_reader_racing_the_forget_keeps_the_track() { + let mut front = serving(remote(1, 10), 100); + let t0 = Instant::now(); + front.step(Event::Unused { + track: name("video"), + now: t0, + }); + assert_actions( + front.step(Event::Deadline { now: t0 + LINGER }), + &[Action::Forget { track: name("video") }], ); - // A returning reader re-splices from the serving source. assert_actions( front.step(Event::Used { track: name("video") }), &[Action::Query { @@ -1129,6 +1182,100 @@ mod tests { ); } + /// A finished track stays for readers still draining it and for the linger + /// after the last one, is never spliced again, and is then forgotten. + #[test] + fn a_finished_track_lingers_then_is_forgotten() { + let mut front = serving(remote(1, 10), 100); + assert_actions( + front.step(Event::TrackEnded { + track: name("video"), + source: 100, + closing: false, + result: Ok(()), + delivered: true, + }), + &[Action::Finish { track: name("video") }], + ); + let t0 = Instant::now(); + assert_actions( + front.step(Event::Unused { + track: name("video"), + now: t0, + }), + &[Action::Arm { at: Some(t0 + LINGER) }], + ); + // A returning reader reads what it holds: no query, and the linger waits. + assert_actions( + front.step(Event::Used { track: name("video") }), + &[Action::Arm { at: None }], + ); + // A replacement source does not re-splice it either. + front.step(Event::Selected { + best: Some(remote(2, 10)), + serving_closing: false, + }); + assert_actions( + front.step(Event::Resolved { + route: 2, + result: Ok(200), + }), + &[Action::Detach { source: 100 }], + ); + let t1 = t0 + LINGER; + front.step(Event::Unused { + track: name("video"), + now: t1, + }); + assert_actions( + front.step(Event::Deadline { now: t1 + LINGER }), + &[Action::Forget { track: name("video") }], + ); + front.step(Event::Forgotten { track: name("video") }); + assert!(!front.tracks.contains_key(&name("video"))); + } + + /// An aborted track lingers the same way rather than staying for the life of + /// the front. + #[test] + fn an_aborted_track_lingers_then_is_forgotten() { + let mut front = serving(remote(1, 10), 100); + front.step(Event::TrackEnded { + track: name("video"), + source: 100, + closing: false, + result: Err(Error::Dropped), + delivered: false, + }); + let t0 = Instant::now(); + assert_actions( + front.step(Event::Unused { + track: name("video"), + now: t0, + }), + &[Action::Arm { at: Some(t0 + LINGER) }], + ); + assert_actions( + front.step(Event::Deadline { now: t0 + LINGER }), + &[Action::Forget { track: name("video") }], + ); + front.step(Event::Forgotten { track: name("video") }); + assert!(!front.tracks.contains_key(&name("video"))); + } + + /// A local source keeps its own cache, so an unread track is forgotten at once. + #[test] + fn an_unread_local_track_is_forgotten_at_once() { + let mut front = serving(local(1), 100); + assert_actions( + front.step(Event::Unused { + track: name("video"), + now: Instant::now(), + }), + &[Action::Forget { track: name("video") }], + ); + } + #[test] fn a_returning_reader_cancels_the_linger() { let mut front = serving(remote(1, 10), 100); @@ -1149,11 +1296,21 @@ mod tests { ); } + /// A track whose reader left before the front saw it is never spliced, and is + /// forgotten after the linger rather than kept as a stub. #[test] fn unread_track_is_never_spliced() { let mut front = serving(remote(1, 10), 100); - assert_actions(front.step(Event::TrackAssigned { track: name("audio") }), &[]); - assert_eq!(front.tracks[&name("audio")].state, TrackState::Idle); + let t0 = Instant::now(); + front.step(Event::TrackAssigned { + track: name("audio"), + now: t0, + }); + assert_eq!(front.tracks[&name("audio")].state, TrackState::Parked { since: t0 }); + assert_actions( + front.step(Event::Deadline { now: t0 + LINGER }), + &[Action::Forget { track: name("audio") }], + ); } #[test] @@ -1231,7 +1388,10 @@ mod tests { }), }, Event::SourceClosed { source: 100 }, - Event::TrackAssigned { track: name("v") }, + Event::TrackAssigned { + track: name("v"), + now: t0, + }, Event::Used { track: name("v") }, Event::Unused { track: name("v"), @@ -1257,6 +1417,7 @@ mod tests { delivered: false, }, Event::Deadline { now: t0 + LINGER }, + Event::Forgotten { track: name("v") }, ]; fn walk(front: &Front, alphabet: &[Event], depth: usize, sequences: &mut usize) { diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index 77a5516a34..f458d076da 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -21,6 +21,7 @@ mod requests; pub(crate) mod resume; mod subscription; mod time; +mod timed; mod weak_cache; #[cfg(test)] @@ -35,6 +36,7 @@ pub(crate) use subscription::Cap; // not under a role module. pub use datagram::*; pub use time::*; +pub use timed::Timed; /// Publishing broadcasts, announcing routes, and consuming both through an origin. pub mod origin { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a9345af718..a992112d9a 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -19,6 +19,7 @@ use super::{ use crate::{ AsPath, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, coding::{BoundsExceeded, Decode, DecodeError, Encode, EncodeError}, + path::Segment, runtime::{Instant, Timers}, time::Clock, util::{Keepalive, TaskSet, Tasks, TasksWeak}, @@ -778,6 +779,10 @@ struct RouteEntry { /// through it. A broadcast is in the table from creation but serves nobody, /// locally or remotely, until it announces. advertised: bool, + /// Whether a peer the chain passes through has since withdrawn this prefix. + /// The route was derived from that peer's advertisement, so it serves nobody + /// until the peer announces again. See [`Dynamic::withdrawn`]. + stale: bool, /// [`prefix_claim`] of [`Self::prefix`], built once at announce time. /// /// The announce sync evaluates a route's claim once per (cursor, route) pair, @@ -787,6 +792,11 @@ struct RouteEntry { } impl RouteEntry { + /// Whether cursors see the entry and requests resolve through it. + fn live(&self) -> bool { + self.advertised && !self.stale + } + fn is_anonymous(&self) -> bool { self.hops.iter().any(|hop| *hop == Hop::UNKNOWN) } @@ -936,8 +946,18 @@ struct TableCursor { horizon: Horizon, /// Which routes beneath a hidden segment are reported. hidden: Hidden, - /// The delivery buffer, drained by the cursor's `poll_next`. + /// The delivery buffer, drained by the cursor's `poll_next`. A mounted + /// consumer's cursors share one. state: kio::Producer, + /// Where this cursor's presented prefixes land in the delivery buffer: empty, + /// or the mount the cursor reads for, relative to the consumer's root. + under: PathOwned, + /// The absolute prefixes a mount shadows: routes at or beneath them are not + /// this cursor's to present. + holes: Vec, + /// For a cursor reading through a mount: the mount and the handle's own + /// patterns, so a wildcard captures what the handle names rather than the target. + named: Option<(Mount, Patterns)>, /// The last delivered best route per presented (relative) prefix, for change /// detection: `(entry id, hops, cost)`. // entry id, metadata, and whether the entry could serve requests: the last @@ -965,8 +985,11 @@ impl TableCursor { /// What the cursor's most specific matching scope member captures from an /// exact announced prefix. An overlap-only route does not pin every wildcard. fn captures(&self, prefix: &Path) -> Option> { - let literal = Pattern::literal(prefix.as_str()).ok()?; - self.allowed + let (literal, allowed) = match &self.named { + Some((mount, allowed)) => (Pattern::literal(mount.name(prefix)?.as_str()).ok()?, allowed), + None => (Pattern::literal(prefix.as_str()).ok()?, &self.allowed), + }; + allowed .iter() .filter_map(|allowed| { allowed @@ -978,23 +1001,98 @@ impl TableCursor { } /// Whether this cursor may observe `entry` at all: advertised, not behind - /// the excluded peer (split horizon), and within the cursor's patterns. + /// the excluded peer (split horizon), within the cursor's patterns, and + /// nameable through its mount. fn visible(&self, entry: &RouteEntry) -> bool { - entry.advertised && self.horizon.admits(entry) && entry.overlaps(&self.allowed) && self.discovers(&entry.prefix) + entry.live() + && self.horizon.admits(entry) + && entry.overlaps(&self.allowed) + && self.discovers(&entry.prefix) + && !self.holes.iter().any(|hole| entry.prefix.has_prefix(hole)) + && self.named.as_ref().is_none_or(|(mount, _)| mount.names(&entry.prefix)) } /// Whether the hidden rule lets this cursor discover a route at `prefix`. fn discovers(&self, prefix: &Path) -> bool { - (self.hidden.include || !hides(&self.heads, prefix)) - && self.hidden.beyond.as_ref().is_none_or(|outer| hides(outer, prefix)) + self.hidden.discovers(&self.heads, prefix) } } -/// A handle's view of an origin: the absolute patterns it may reach. +/// A handle's view of an origin: the absolute patterns it may reach, and the +/// subtrees it reads from elsewhere on the origin. #[derive(Clone)] struct OriginScope { - // The paths this handle may reach, absolute. + // The paths this handle may reach, absolute, named as the handle sees them: + // a path under a mount is authorized here, before it is resolved. allowed: Patterns, + // The subtrees that resolve elsewhere; see [`Producer::mount`]. Disjoint. + mounts: Arc<[Mount]>, +} + +/// A subtree a handle reads from elsewhere on the origin: `at/rest` resolves at +/// `target/rest`. Both absolute. +#[derive(Clone, Debug)] +struct Mount { + at: PathOwned, + target: PathOwned, +} + +impl Mount { + /// Where the handle-side absolute `path` resolves, when it is under this mount + /// and the result fits [`Path::MAX_PARTS`]. + fn resolve(&self, path: &Path) -> Option { + let resolved = self.target.join(path.strip_prefix(&self.at)?); + (resolved.parts().count() <= Path::MAX_PARTS).then_some(resolved) + } + + /// Where the origin-side absolute `path` shows on the handle, when it is at or + /// beneath the target. A route covering the target has no handle-side name. + fn name(&self, path: &Path) -> Option { + Some(self.at.join(path.strip_prefix(&self.target)?)) + } + + /// Whether the origin-side absolute `path` has a handle-side name within + /// [`Path::MAX_PARTS`], so a reader could ask for it. A route covering the + /// target always does. + fn names(&self, path: &Path) -> bool { + path.strip_prefix(&self.target) + .is_none_or(|rest| self.at.parts().count() + rest.parts().count() <= Path::MAX_PARTS) + } + + /// The handle-side absolute `patterns` beneath this mount, as origin-side + /// patterns beneath its target. + /// + /// A member too deep to root matches no valid path and drops, except that a + /// `**` one segment past the limit can only match nothing and is dropped instead, + /// so a deep target keeps its exact path. + fn translate(&self, patterns: &Patterns) -> Patterns { + let target = self.target.as_str(); + patterns + .rebase(self.at.as_str()) + .iter() + .filter_map(|member| { + member + .rooted(target) + .or_else(|_| { + let segments = member + .segments() + .iter() + .filter(|segment| **segment != Segment::Globstar); + Pattern::new(segments.cloned())?.rooted(target) + }) + .ok() + }) + .collect() + } + + /// A handle-side interest head as an origin-side one: a head beneath the mount + /// moves under the target, and one above it hangs at the target itself. + fn translate_head(&self, head: &Path) -> Option { + match head.strip_prefix(&self.at) { + Some(rest) => Some(self.target.join(rest)), + None => self.at.has_prefix(head).then(|| self.target.clone()), + } + } } impl OriginScope { @@ -1002,6 +1100,7 @@ impl OriginScope { fn empty() -> Self { Self { allowed: Patterns::new(), + mounts: Arc::from([]), } } @@ -1011,10 +1110,33 @@ impl OriginScope { if allowed.is_empty() { None } else { - Some(Self { allowed }) + Some(Self { + allowed, + mounts: self.mounts.clone(), + }) + } + } + + /// The mount the absolute `path` is under, if any. + fn mount(&self, path: &Path) -> Option<&Mount> { + self.mounts.iter().find(|mount| path.has_prefix(&mount.at)) + } + + /// Where the absolute `path` resolves on the origin: itself, or its mount + /// target. `None` when the mount would resolve it past [`Path::MAX_PARTS`]. + fn resolve<'a>(&self, path: &'a Path<'a>) -> Option> { + match self.mount(path) { + Some(mount) => mount.resolve(path), + None => Some(path.borrow()), } } + /// Whether this view may publish at the absolute `prefix`: a mount is read-only, + /// so nothing is published at or beneath one. + fn publishes(&self, prefix: &Path) -> bool { + self.mount(prefix).is_none() + } + /// Whether this view reaches the absolute `path`. fn permits(&self, path: &Path) -> bool { self.allowed.matches(path.as_str()) @@ -1030,6 +1152,7 @@ impl Default for OriginScope { fn default() -> Self { Self { allowed: Patterns::from(Pattern::all()), + mounts: Arc::from([]), } } } @@ -1063,6 +1186,26 @@ pub(crate) struct Hidden { beyond: Option>, } +impl Hidden { + /// Whether a cursor hanging at `heads` reports a route at `prefix`. + fn discovers(&self, heads: &[PathOwned], prefix: &Path) -> bool { + (self.include || !hides(heads, prefix)) && self.beyond.as_ref().is_none_or(|outer| hides(outer, prefix)) + } + + /// This rule for a cursor reading through `mount`, whose heads sit on the + /// target: a feed it tops up hid everything under the mount, or hid what it + /// hides under the target. + fn translate(&self, mount: &Mount) -> Self { + let beyond = self.beyond.as_ref().and_then(|outer| { + (!hides(outer, &mount.at)).then(|| outer.iter().filter_map(|head| mount.translate_head(head)).collect()) + }); + Self { + include: self.include, + beyond, + } + } +} + /// Whether a segment of `prefix` below the head it sits under starts with `.`. /// A route at or above a head has nothing below it, so it never hides. fn hides(heads: &[PathOwned], prefix: &Path) -> bool { @@ -1265,7 +1408,8 @@ impl Producer { /// 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)), + /// producer may publish under (after [`scope`](Self::scope)) or beneath a + /// [`mount`](Self::mount), /// [`Error::BoundsExceeded`] if the full rooted path exceeds /// [`Path::MAX_PARTS`], [`Error::InvalidPath`] if it holds a segment no /// pattern can spell (`*` or `**`), or [`Error::Closed`] once the origin's @@ -1274,7 +1418,7 @@ impl Producer { let path = path.as_path(); let full = self.root.join(&path).to_owned(); - if !self.scope.permits(&full) { + if !self.scope.permits(&full) || !self.scope.publishes(&full) { return Err(Error::Unauthorized); } // A decoded prefix and suffix are each within the wire limit, but their @@ -1462,6 +1606,71 @@ impl Producer { }) } + /// Returns a producer that reads the subtree at `at` from `target` instead. + /// + /// Both are relative to this producer's root. A consumer derived from the + /// result resolves `at/rest` at `target/rest`, through the one front serving + /// that path, and presents the routes under `target` (or covering it) under + /// `at`. Permissions stay named from the handle's side: a later + /// [`scope`](Self::scope) authorizes `at/rest` as written. The mount is + /// read-only: nothing is published at or beneath `at`, so what a reader finds + /// there is only ever `target`'s. Whatever the origin holds at `at` itself is + /// hidden from the mounted handle. + /// + /// Returns [`Error::Unauthorized`] unless this producer reaches all of + /// `target`, so a mount never widens a scope, and [`Error::Duplicate`] when + /// `at` or `target` overlaps a mount point this producer already has, or `at` + /// overlaps an existing target or its own: mounts never chain, in whatever order they are added, + /// so a target is always read as the origin holds it. Mounts may share a target. + /// [`Error::BoundsExceeded`] if either rooted path exceeds [`Path::MAX_PARTS`], + /// and [`Error::InvalidPath`] if `at` holds a segment no pattern can spell. + pub fn mount(&self, at: impl AsPath, target: impl AsPath) -> Result { + let at = self.root.join(at).to_owned(); + let target = self.root.join(target).to_owned(); + if [&at, &target] + .into_iter() + .any(|path| path.parts().count() > Path::MAX_PARTS) + { + return Err(BoundsExceeded.into()); + } + // A mount point no pattern can spell could never be announced or authorized. + Pattern::literal(at.as_str())?; + if !self + .scope + .allowed + .covers(&Patterns::from(Pattern::subtree(target.as_str())?)) + { + return Err(Error::Unauthorized); + } + // Symmetric, so the order mounts are added never changes which sets are accepted: + // no mount point overlaps any mount's point or target, its own included. + // Targets may overlap. + let overlaps = |a: &Path, b: &Path| a.has_prefix(b) || b.has_prefix(a); + if overlaps(&at, &target) + || self + .scope + .mounts + .iter() + .any(|mount| overlaps(&at, &mount.at) || overlaps(&target, &mount.at) || overlaps(&at, &mount.target)) + { + return Err(Error::Duplicate); + } + let mounts = self + .scope + .mounts + .iter() + .cloned() + .chain([Mount { at, target }]) + .collect(); + Ok(Producer { + scope: OriginScope { + allowed: self.scope.allowed.clone(), + mounts, + }, + ..self.clone() + }) + } + /// Cheap read handle over this origin's route table. /// /// Use [`Consumer::announced`] to register interest and start receiving @@ -1516,7 +1725,7 @@ impl Announcing { return Err(BoundsExceeded.into()); } let claim = prefix_claim(&requested)?; - if !producer.scope.allowed.overlaps(&claim) { + if !producer.scope.allowed.overlaps(&claim) || !producer.scope.publishes(&requested) { return Err(Error::Unauthorized); } Ok(Self { @@ -1546,8 +1755,10 @@ impl Announcing { let mut entries = Vec::with_capacity(self.prefixes.len()); for (prefix, claim) in &self.prefixes { + shared.reannounced(prefix, &route.hops); let id = shared.next_route; shared.next_route += 1; + let stale = shared.withdrawn_through(prefix, &route.hops); shared.routes.insert(RouteEntry { id, prefix: prefix.clone(), @@ -1560,6 +1771,7 @@ impl Announcing { server: serving.server.clone(), source: serving.source.clone(), advertised: serving.advertised, + stale, claim: claim.clone(), }); shared.sync_route(prefix, claim); @@ -1650,16 +1862,20 @@ impl AnnounceProducer { return Err(Error::Closed); } for (prefix, id) in &self.entries { + shared.reannounced(prefix, &route.hops); + let stale = shared.withdrawn_through(prefix, &route.hops); // Each entry keeps its advertised prefix; only the metadata moves. let Some(entry) = shared.routes.entry_mut(prefix, *id) else { return Err(Error::Closed); }; entry.hops = route.hops.clone(); + entry.stale = stale; entry.cost = route.cost; entry.via = route.via; entry.advertised = true; let claim = entry.claim.clone(); shared.sync_route(prefix, &claim); + shared.prune_withdrawn(prefix); } Ok(()) } @@ -1685,7 +1901,7 @@ impl AnnounceProducer { /// Retract the route now: remove its table entries and reject anything still /// waiting on its queue. Idempotent, and what dropping the advertisement does. - fn retract(&self) { + fn retract(&self, withdrawn: bool) { let mut shared = self.shared.lock(); for (prefix, id) in &self.entries { let Some(entry) = shared.routes.remove(prefix, *id) else { @@ -1702,14 +1918,30 @@ impl AnnounceProducer { } } } + // A peer remains reachable while any of its sessions advertises this + // prefix. Remove this session's route before testing the remaining + // claims, under the same lock, so overlapping withdrawals cannot + // invalidate a newer advertisement or miss the last withdrawal. + if withdrawn + && let Some(&peer) = entry.hops.iter().last() + && peer != Hop::UNKNOWN + && !shared + .routes + .at(prefix) + .any(|other| other.live() && other.hops.iter().last() == Some(&peer)) + { + shared.withdrawn.entry(prefix.clone()).or_default().insert(peer); + shared.restale(prefix); + } shared.sync_route(&entry.prefix, &entry.claim); + shared.prune_withdrawn(&entry.prefix); } } } impl Drop for AnnounceProducer { fn drop(&mut self) { - self.retract(); + self.retract(false); } } @@ -1848,7 +2080,8 @@ impl Drop for DriverState { /// Within the window a returning viewer, or the next of a run of back-to-back /// fetches, reads the groups the front already cached: no second round trip for /// `TRACK_INFO`. Groups past that cached edge cost a fresh source splice. After -/// the window, the cached segment is released. +/// the window the track is forgotten, finished and aborted ones included, so a +/// long-lived broadcast only holds the tracks read recently. /// /// Sized above the fetch cadence of a segmented consumer: HLS polls every /// `TARGETDURATION` seconds, commonly 6 or 10, so a shorter window would drop the @@ -2055,6 +2288,18 @@ struct TrackIo { used: bool, } +impl TrackIo { + /// Let go of every source handle once the logical track ended: its segments keep + /// what readers drain, and only its demand is still watched, until it is forgotten. + fn end(&mut self) { + self.staged = None; + self.query = None; + self.copy = None; + self.warm = None; + self.head = None; + } +} + /// Drives one front: feeds the world's events to a [`Front`] and performs the /// actions it returns, until the front ends. The decisions live in the machine; /// this only waits and executes, so nothing here decides anything twice. @@ -2131,7 +2376,7 @@ async fn run_front(task: FrontTask) { table .routes .covering(&path.as_path()) - .find(|entry| entry.id == route) + .find(|entry| entry.id == route && entry.live()) .map(|entry| { ( Candidate { @@ -2303,24 +2548,33 @@ async fn run_front(task: FrontTask) { } io.warm = warm; } - Action::Release { track: name } => { - let Some(io) = tracks.get_mut(&name) else { continue }; - // A local source releases straight from the spliced copy. - io.copy = None; - io.warm = None; - io.head = None; - if io.resume.release().is_err() { - tracks.remove(&name); + Action::Forget { track: name } => { + // A reader that looked the track up since the machine decided keeps + // it. Feed its `Used` edge here: the demand poll only sees the + // current level, so a reader gone before the next poll would + // otherwise leave the track unread with no linger armed. + if let Some(io) = tracks.get_mut(&name) + && !broadcast.forget_spliced(&name, &io.resume) + { + if !io.used { + io.used = true; + events.push_back(Event::Used { track: name }); + } + continue; } + tracks.remove(&name); + events.push_back(Event::Forgotten { track: name }); } Action::Finish { track: name } => { - if let Some(mut io) = tracks.remove(&name) { + if let Some(io) = tracks.get_mut(&name) { + io.end(); let _ = io.resume.finish(); } } Action::Abort { track: name, err } => { - if let Some(mut io) = tracks.remove(&name) { + if let Some(io) = tracks.get_mut(&name) { tracing::debug!(name = %name, %err, "aborting track"); + io.end(); let _ = io.resume.abort(err); } } @@ -2429,7 +2683,10 @@ async fn run_front(task: FrontTask) { used: false, }, ); - Event::TrackAssigned { track: name } + Event::TrackAssigned { + track: name, + now: timers.now(), + } } Step::Resolved(route, result) => { upstream = None; @@ -2720,9 +2977,9 @@ impl RouteTable { above.into_iter().chain(at).flat_map(|node| node.entries.iter()) } - /// Whether the route `id` still covers `path`. + /// Whether the live route `id` still covers `path`. fn covers(&self, path: &Path, id: u64) -> bool { - self.covering(path).any(|entry| entry.id == id) + self.covering(path).any(|entry| entry.id == id && entry.live()) } /// The routes announced exactly at `prefix`. @@ -2733,6 +2990,15 @@ impl RouteTable { .flat_map(|node| node.entries.iter()) } + /// The routes announced exactly at `prefix`, for a change in place. + fn at_mut(&mut self, prefix: &Path) -> impl Iterator { + let mut node = Some(&mut self.root); + for part in prefix.parts() { + node = node.and_then(|node| node.children.get_mut(part)); + } + node.into_iter().flat_map(|node| node.entries.iter_mut()) + } + /// Every route in the table, for the teardown. fn entries(&self) -> impl Iterator { let mut nodes = Vec::new(); @@ -2855,6 +3121,10 @@ struct OriginState { replaying: HashMap, next_replaying: u64, + // Peers that withdrew a prefix while routes there still passed through them, + // which are stale. See [`Dynamic::withdrawn`]. + withdrawn: HashMap>, + // Set when the origin's driver dropped: new requests fail with `Closed` // immediately and handlers observe the end instead of parking forever. closed: bool, @@ -2869,6 +3139,72 @@ struct ReplayingSource { } impl OriginState { + /// Whether a peer in `hops` withdrew `prefix`. + fn withdrawn_through(&self, prefix: &Path, hops: &Hops) -> bool { + if self.withdrawn.is_empty() { + return false; + } + self.withdrawn + .get(prefix) + .is_some_and(|peers| hops.iter().any(|hop| peers.contains(hop))) + } + + /// Recompute which entries at `prefix` are stale after its withdrawals changed. + fn restale(&mut self, prefix: &Path) { + let peers = self.withdrawn.get(prefix); + let mut changed = None; + for entry in self.routes.at_mut(prefix) { + let stale = peers.is_some_and(|peers| entry.hops.iter().any(|hop| peers.contains(hop))); + if entry.stale != stale { + entry.stale = stale; + changed = Some(entry.claim.clone()); + } + } + if let Some(claim) = changed { + self.sync_route(prefix, &claim); + } + } + + /// A route at `prefix` was announced or restarted with `hops`. When its sender, + /// the chain's last hop, had withdrawn the prefix, routes through it are live + /// once more. + fn reannounced(&mut self, prefix: &PathOwned, hops: &Hops) { + if let Some(sender) = hops.iter().last() + && self.withdrawn.get_mut(prefix).is_some_and(|peers| peers.remove(sender)) + { + self.restale(prefix); + self.prune_withdrawn(prefix); + } + } + + /// Forget the withdrawals at `prefix` that no longer hide a route. + fn prune_withdrawn(&mut self, prefix: &PathOwned) { + if self.withdrawn.is_empty() { + return; + } + let Some(peers) = self.withdrawn.get_mut(prefix) else { + return; + }; + if peers.len() <= 1 { + // The common case is already linear and needs no temporary allocation. + peers.retain(|peer| self.routes.at(prefix).any(|entry| entry.hops.contains(peer))); + } else { + let mut unreferenced = peers.clone(); + for entry in self.routes.at(prefix) { + if unreferenced.is_empty() { + break; + } + for hop in entry.hops.iter() { + unreferenced.remove(hop); + } + } + peers.retain(|peer| !unreferenced.contains(peer)); + } + if peers.is_empty() { + self.withdrawn.remove(prefix); + } + } + /// Re-deliver the best route at every presented prefix `prefix` maps to, on /// every cursor it can present on. Called after an entry covering `prefix` /// was added, updated, or removed. `claim` is `prefix`'s [`prefix_claim`], @@ -2948,13 +3284,13 @@ impl OriginState { // old identity explicitly so capture-keyed consumers can remove it. Some((_, prev, _, prev_captures)) if prev_captures != captures => { if let Ok(mut state) = cursor.state.write() { - state.apply_unannounce(presented.clone(), prev, prev_captures); - state.apply_announce(presented.clone(), meta, captures); + state.apply_unannounce(cursor.under.join(presented), prev, prev_captures); + state.apply_announce(cursor.under.join(presented), meta, captures); } } _ => { if let Ok(mut state) = cursor.state.write() { - state.apply_announce(presented.clone(), meta, captures); + state.apply_announce(cursor.under.join(presented), meta, captures); } } } @@ -2963,7 +3299,7 @@ impl OriginState { if let Some((_, last, _, captures)) = cursor.current.remove(presented) && let Ok(mut state) = cursor.state.write() { - state.apply_unannounce(presented.clone(), last, captures); + state.apply_unannounce(cursor.under.join(presented), last, captures); } } } @@ -3032,7 +3368,7 @@ impl OriginState { let mut candidates = node .entries .iter() - .filter(|entry| entry.advertised) + .filter(|entry| entry.live()) .filter(|entry| entry.scope.matches(path.as_str())) .filter(|entry| horizon.admits(entry)) .filter(|entry| entry.qualifies(pin)) @@ -3139,6 +3475,12 @@ pub struct Dynamic { } impl Dynamic { + /// Retire an explicit peer withdrawal, hiding derived paths only once its + /// last live advertisement at the prefix is gone. A lost session just drops. + pub(crate) fn withdrawn(self) { + self.announcement.retract(true); + } + /// Re-price the route in place: replace its hops and cost. /// /// Consumers observe another active update for the same prefix; sessions @@ -3596,14 +3938,80 @@ impl Consumer { /// patterns are hidden unless [`with_hidden`](Self::with_hidden) opted in. /// Drop the returned [`AnnounceConsumer`] to unregister. pub fn announced(&self) -> AnnounceConsumer { - AnnounceConsumer::new( - self.root.clone(), - self.scope.allowed.clone(), - self.stats.clone(), - self.horizon, - self.hidden.clone(), - &self.shared, - ) + let state = kio::Producer::::default(); + let cursor = |root: PathOwned, + allowed: Patterns, + hidden: Hidden, + mount: Option<&Mount>, + under: PathOwned, + holes: Vec| TableCursor { + root, + heads: interest_prefixes(&allowed), + allowed, + horizon: self.horizon, + hidden, + state: state.clone(), + under, + holes, + named: mount.map(|mount| (mount.clone(), self.scope.allowed.clone())), + current: HashMap::new(), + }; + + // A root at or beneath a mount reads only the mount: one cursor, re-rooted + // onto the target. + if let Some(mount) = self.scope.mount(&self.root) { + // A root the mount resolves past the depth limit has nothing to announce. + let Some(root) = mount.resolve(&self.root) else { + return AnnounceConsumer::new(self.root.clone(), Vec::new(), state, self.stats.clone(), &self.shared); + }; + let cursors = vec![cursor( + root, + mount.translate(&self.scope.allowed), + self.hidden.translate(mount), + Some(mount), + PathOwned::default(), + Vec::new(), + )]; + return AnnounceConsumer::new(self.root.clone(), cursors, state, self.stats.clone(), &self.shared); + } + + // Otherwise the consumer's own cursor, minus the mounted subtrees, plus one + // cursor per mount beneath the root it may discover, presenting under it. + let heads = interest_prefixes(&self.scope.allowed); + let mut holes = Vec::new(); + let mut cursors = Vec::new(); + for mount in self.scope.mounts.iter() { + let Some(under) = mount.at.strip_prefix(&self.root) else { + continue; + }; + holes.push(mount.at.clone()); + let allowed = mount.translate(&self.scope.allowed); + // Hidden at the mount point means hidden throughout: the dot segment is above + // everything the mount holds. A top-up feed's rule moves onto the target. + if allowed.is_empty() || !(self.hidden.include || !hides(&heads, &mount.at)) { + continue; + } + cursors.push(cursor( + mount.target.clone(), + allowed, + self.hidden.translate(mount), + Some(mount), + under.to_owned(), + Vec::new(), + )); + } + cursors.insert( + 0, + cursor( + self.root.clone(), + self.scope.allowed.clone(), + self.hidden.clone(), + None, + PathOwned::default(), + holes, + ), + ); + AnnounceConsumer::new(self.root.clone(), cursors, state, self.stats.clone(), &self.shared) } /// Returns a cheap duplicate of this read handle. @@ -3619,6 +4027,7 @@ impl Consumer { if !self.scope.permits(&full) { return None; } + let full = self.scope.resolve(&full)?; let table = self.shared.lock(); table .routes @@ -3705,7 +4114,9 @@ impl Consumer { if table.closed { return Err(Error::Closed); } - let watch = table.watch(&self.shared, &self.root.join(&path)); + let named = self.root.join(&path); + let resolved = self.scope.resolve(&named).ok_or(BoundsExceeded)?; + let watch = table.watch(&self.shared, &resolved); let seen = watch.seen(); (watch, seen) }; @@ -3764,21 +4175,27 @@ impl Consumer { pub fn request_broadcast(&self, path: impl AsPath) -> kio::Pending { let path = path.as_path(); - // Key requests by absolute path so scoped/rooted consumers and handlers - // (which may have a different root) agree on the same entry, and so the egress - // counters resolve against the same broadcast the ingress side wrote. - let absolute = self.root.join(&path).to_owned(); - let scope = self.stats.egress(&absolute); + // The path as this handle names it, absolute: what its scope authorizes and + // what its egress is counted under, so a read through a mount bills where + // the reader asked. + let named = self.root.join(&path).to_owned(); + let scope = self.stats.egress(&named); // The resolved handle is named by what *this* cursor asked for, not by the absolute // path: a rooted cursor cannot name anything above its own root, so that is what a // catalog it reads may reference. let requested = path.to_owned(); // Routes only cover paths within this consumer's scope. - if !self.scope.permits(&absolute) { + if !self.scope.permits(&named) { return kio::Pending::new(Requesting::failed(Error::Unauthorized)); } + // Key requests by the absolute path on the origin, past any mount, so scoped, + // rooted, and mounted consumers and handlers agree on the same entry and front. + let Some(absolute) = self.scope.resolve(&named).map(|path| path.to_owned()) else { + return kio::Pending::new(Requesting::failed(BoundsExceeded.into())); + }; + let mut state = self.shared.lock(); // The origin's driver dropped: nothing will ever serve this. @@ -3876,7 +4293,9 @@ impl Consumer { /// Created by [`Consumer::announced`]. /// Drop to unregister. pub struct AnnounceConsumer { - id: ConsumerId, + // One registration per table cursor feeding `state`: more than one when the + // consumer reads through a mount. + ids: Vec, shared: kio::Shared, root: PathOwned, @@ -3901,15 +4320,12 @@ pub struct AnnounceConsumer { impl AnnounceConsumer { fn new( root: PathOwned, - allowed: Patterns, + cursors: Vec, + state: kio::Producer, stats: stats::Session, - horizon: Horizon, - hidden: Hidden, shared: &kio::Shared, ) -> Self { - let state = kio::Producer::::default(); - let id = ConsumerId::new(); - + let mut ids = Vec::with_capacity(cursors.len()); { let mut table = shared.lock(); if table.closed { @@ -3918,23 +4334,16 @@ impl AnnounceConsumer { state.ended = true; } } else { - table.register_cursor( - id, - TableCursor { - root: root.clone(), - heads: interest_prefixes(&allowed), - allowed, - horizon, - hidden, - state: state.clone(), - current: HashMap::new(), - }, - ); + for cursor in cursors { + let id = ConsumerId::new(); + table.register_cursor(id, cursor); + ids.push(id); + } } } Self { - id, + ids, shared: shared.clone(), root, state, @@ -4041,9 +4450,11 @@ impl futures::Stream for AnnounceConsumer { impl Drop for AnnounceConsumer { fn drop(&mut self) { let mut shared = self.shared.lock(); - if let Some(cursor) = shared.cursors.remove(&self.id) { - for head in &cursor.heads { - shared.routes.remove_cursor(head, self.id); + for id in &self.ids { + if let Some(cursor) = shared.cursors.remove(id) { + for head in &cursor.heads { + shared.routes.remove_cursor(head, *id); + } } } } @@ -4315,6 +4726,277 @@ mod tests { assert_eq!(resolved.info().path.as_str(), ".stats/node"); } + /// A project-rooted handle reading `.svc` from the fleet-wide `.svc/`. + fn mounted(producer: &Producer, pid: &str, patterns: &[&str]) -> Consumer { + let patterns: Patterns = patterns.iter().map(|pattern| pattern.parse().unwrap()).collect(); + producer + .mount(format!("{pid}/.svc"), format!(".svc/{pid}")) + .unwrap() + .scope(pid, &patterns) + .unwrap() + .consume() + } + + /// A request through a mount is a request for the target path: it joins the + /// one front there, so the fleet-wide claim is asked once for both readers. + #[tokio::test] + async fn mount_resolves_on_the_target_front() { + let producer = origin(1).produce(); + let server = producer.dynamic(".svc", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]); + + let through = project.request_broadcast(".svc/foo"); + let direct = producer.consume().request_broadcast(".svc/p1/foo"); + + let request = queued(&server).await; + assert_eq!(request.path().as_str(), ".svc/p1/foo"); + assert!(server.poll_requested_broadcast(&kio::Waiter::noop()).is_pending()); + let source = broadcast::Info::new().produce(); + request.accept(&source); + + let through = through.await.expect("resolves"); + let direct = direct.await.expect("resolves"); + assert!(through.is_clone(&direct)); + // Named by what the reader asked for. + assert_eq!(through.info().path.as_str(), ".svc/foo"); + } + + /// Routes under the target, and the claim covering it, present under the + /// mount; what the origin holds at the mounted path itself does not. + #[tokio::test] + async fn mount_presents_target_routes_under_the_mount() { + let producer = origin(1).produce(); + let _claim = producer.announce(".svc", Route::default()).unwrap(); + let foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let _other = producer.publish(".svc/p2/bar", Route::default()).unwrap(); + let _cam = producer.publish("p1/cam", Route::default()).unwrap(); + let _shadowed = producer.publish("p1/.svc/forged", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]); + + // A dot segment hides the mount like any other. + let mut announced = project.announced(); + announced.assert_next_active("cam"); + announced.assert_next_wait(); + + let mut announced = project.clone().with_hidden(true).announced(); + announced.assert_next_active(".svc"); + announced.assert_next_active(".svc/foo"); + announced.assert_next_active("cam"); + announced.assert_next_wait(); + + // Rooted inside the mount, the claim covers the root itself. + let mut inside = project + .scope(".svc", &Patterns::from(Pattern::all())) + .unwrap() + .announced(); + inside.assert_next_active(""); + inside.assert_next_active("foo"); + inside.assert_next_wait(); + + drop(foo); + announced.assert_next_ended(".svc/foo"); + inside.assert_next_ended("foo"); + announced.assert_next_wait(); + + // The shadowed path never resolves: the mount answers for it. + let err = project.request_broadcast(".svc/forged").await.err().unwrap(); + assert!(matches!(err, Error::NotFound | Error::Unroutable), "{err:?}"); + } + + /// The handle's own patterns authorize a path through the mount, named as the + /// handle names it. + #[tokio::test] + async fn mount_authorizes_the_named_path() { + let producer = origin(1).produce(); + let _foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let _bar = producer.publish(".svc/p1/bar", Route::default()).unwrap(); + + let granted = mounted(&producer, "p1", &["foo", ".svc/foo"]); + granted.request_broadcast(".svc/foo").await.expect("granted"); + let refused = granted + .request_broadcast(".svc/bar") + .now_or_never() + .expect("refused at once"); + assert!(matches!(refused, Err(Error::Unauthorized))); + let mut announced = granted.clone().with_hidden(true).announced(); + announced.assert_next_active(".svc/foo"); + announced.assert_next_wait(); + + let narrower = mounted(&producer, "p1", &["foo"]); + let refused = narrower + .request_broadcast(".svc/foo") + .now_or_never() + .expect("refused at once"); + assert!(matches!(refused, Err(Error::Unauthorized))); + narrower.clone().with_hidden(true).announced().assert_next_wait(); + + // Another project's mount reaches its own target, never this one's. + let other = mounted(&producer, "p2", &["**"]); + other.clone().with_hidden(true).announced().assert_next_wait(); + let err = other.request_broadcast(".svc/foo").await.err().unwrap(); + assert!(matches!(err, Error::Unroutable)); + } + + /// A wildcard spanning the mount point captures the path as the handle names + /// it, so capture-keyed consumers key a mounted route like any other. + #[tokio::test] + async fn mount_captures_the_named_path() { + let producer = origin(1).produce(); + let _foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]).with_hidden(true); + + let Some(AnnounceEvent::Start(update)) = project.announced().try_next() else { + panic!("expected foo to start"); + }; + assert_eq!(update.prefix.as_str(), ".svc/foo"); + assert_eq!(update.captures, Some(vec![".svc/foo".parse::().unwrap()])); + + let inside = project.scope(".svc", &Patterns::from(Pattern::all())).unwrap(); + let Some(AnnounceEvent::Start(update)) = inside.announced().try_next() else { + panic!("expected foo to start"); + }; + assert_eq!(update.prefix.as_str(), "foo"); + assert_eq!(update.captures, Some(vec!["foo".parse::().unwrap()])); + } + + /// A target at the maximum depth still presents its exact path: the `**` a + /// wildcard scope carries past it can only match nothing there. + #[tokio::test] + async fn mount_keeps_a_max_depth_target() { + let producer = origin(1).produce(); + let deep = vec!["d"; Path::MAX_PARTS].join("/"); + let _leaf = producer.publish(deep.as_str(), Route::default()).unwrap(); + let project = producer + .mount("p1/.svc", deep.as_str()) + .unwrap() + .scope("p1", &Patterns::from(Pattern::all())) + .unwrap() + .consume() + .with_hidden(true); + + let mut announced = project.announced(); + announced.assert_next_active(".svc"); + announced.assert_next_wait(); + + // Beneath the mount the target has no room: refused, never handed to a route. + let err = project.request_broadcast(".svc/x").await.err().unwrap(); + assert!(matches!(err, Error::BoundsExceeded(_)), "{err:?}"); + let inside = project.scope(".svc/x", &Patterns::from(Pattern::all())).unwrap(); + inside.announced().assert_next_wait(); + } + + /// A mount point deeper than its target never presents a route whose name + /// through the mount is past the depth limit, and a mount point past it is refused. + #[tokio::test] + async fn mount_bounds_the_named_path() { + let producer = origin(1).produce(); + let _near = producer.publish("t/x", Route::default()).unwrap(); + let deep = format!("t/{}", vec!["d"; Path::MAX_PARTS - 1].join("/")); + let _deep = producer.publish(deep.as_str(), Route::default()).unwrap(); + let project = producer + .mount("p1/a/b", "t") + .unwrap() + .scope("p1", &Patterns::from(Pattern::all())) + .unwrap() + .consume(); + + let mut announced = project.announced(); + announced.assert_next_active("a/b/x"); + announced.assert_next_wait(); + + let over = vec!["d"; Path::MAX_PARTS + 1].join("/"); + assert!(matches!( + producer.mount(over.as_str(), "t"), + Err(Error::BoundsExceeded(_)) + )); + } + + /// Nothing is published at or beneath a mount. + #[tokio::test] + async fn mount_is_read_only() { + let producer = origin(1).produce(); + let project = producer + .mount("p1/.svc", ".svc/p1") + .unwrap() + .scope("p1", &Patterns::from(Pattern::all())) + .unwrap(); + assert!(matches!(project.create_broadcast(".svc/foo"), Err(Error::Unauthorized))); + assert!(matches!( + project.dynamic(".svc", Route::default()), + Err(Error::Unauthorized) + )); + assert!(matches!( + project.dynamic(".svc/foo", Route::default()), + Err(Error::Unauthorized) + )); + project.publish("cam", Route::default()).unwrap(); + } + + /// A mount reaches only what the handle already reaches, and mounts never nest. + #[test] + fn mount_never_widens_a_scope() { + let (producer, _driver) = Producer::new(Config::new(origin(1))); + let project = producer.scope("", &scopes(&["p1"])).unwrap(); + assert!(matches!(project.mount("p1/.svc", ".svc/p1"), Err(Error::Unauthorized))); + + let mounted = producer.mount("p1/.svc", ".svc/p1").unwrap(); + assert!(matches!(mounted.mount("p1/.svc/x", ".other"), Err(Error::Duplicate))); + assert!(matches!(mounted.mount("p1", ".other"), Err(Error::Duplicate))); + // A target through a mount would read the subtree the mount shadows. + assert!(matches!(mounted.mount("p2", "p1/.svc/x"), Err(Error::Duplicate))); + assert!(matches!(mounted.mount("p2", "p1"), Err(Error::Duplicate))); + // A mount point on a target would chain through it, in either order. + assert!(matches!(mounted.mount(".svc/p1/x", ".other"), Err(Error::Duplicate))); + assert!(matches!(mounted.mount(".svc", ".other"), Err(Error::Duplicate))); + mounted.mount("p1/.other", ".other/p1").unwrap(); + // Mount points may share a target. + mounted.mount("p2/.svc", ".svc/p1").unwrap(); + // A mount point overlapping its own target is refused like any other overlap. + for (at, target) in [("a", "a/b"), ("a/b", "a"), ("a", "a")] { + assert!( + matches!(producer.mount(at, target), Err(Error::Duplicate)), + "{at} -> {target}" + ); + } + } + + /// A mount point no pattern can spell could never be announced or authorized. + #[test] + fn mount_refuses_a_wildcard_mount_point() { + let (producer, _driver) = Producer::new(Config::new(origin(1))); + for at in ["*", "p1/*", "p1/**", "p1/a*"] { + assert!( + matches!(producer.mount(at, ".svc/p1"), Err(Error::InvalidPath(_))), + "{at}" + ); + } + } + + /// Egress through a mount counts under the path the reader named, so it + /// attributes to the reader's root rather than the fleet-wide target. + #[tokio::test] + async fn mount_egress_counts_under_the_named_path() { + let registry = stats::Registry::new(stats::Config::new()); + let producer = origin(1).produce(); + let _foo = producer.publish(".svc/p1/foo", Route::default()).unwrap(); + let project = mounted(&producer, "p1", &["**"]) + .with_stats(registry.tier(stats::Tier::default()).session("p1")) + .with_hidden(true); + + let mut announced = project.announced(); + announced.assert_next_active(".svc/foo"); + project.request_broadcast(".svc/foo").await.expect("resolves"); + + let mut report = stats::Report::default(); + registry.report(&mut report); + let paths: Vec<_> = report + .traffic + .iter() + .map(|entry| entry.path.as_str().to_string()) + .collect(); + assert_eq!(paths, ["p1/.svc/foo"]); + } + /// A route that turns up later is filtered the same way as the replay. #[tokio::test] async fn hidden_route_announced_later_stays_hidden() { @@ -5133,6 +5815,67 @@ mod tests { assert_eq!(&payload[..], b"snapshot"); } + /// A finished track stays readable from the front while it is read and for the + /// linger after, then leaves the broadcast so it stops pinning its cache: the next + /// reader asks the source afresh. + #[tokio::test(start_paused = true)] + async fn finished_track_is_forgotten_after_the_linger() { + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + + let track = resolved.track("catalog").unwrap(); + let subscribing = tokio::spawn(async move { track.subscribe(None).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source") + .expect("request"); + let source = request.resolving_start().accept(None); + let mut group = source.create_group(0u64.into()).unwrap(); + group.write_frame(crate::Timestamp::ZERO, b"snapshot".as_ref()).unwrap(); + group.finish().unwrap(); + source.finish().unwrap(); + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + assert_eq!( + next_group(&mut subscription) + .await + .unwrap() + .expect("the catalog") + .sequence, + 0 + ); + assert!(next_group(&mut subscription).await.unwrap().is_none()); + drop(subscription); + drop(source); + + // Within the linger, a returning reader gets the finished track from the front. + let mut subscription = resolved.track("catalog").unwrap().subscribe(None).await.unwrap(); + assert_eq!( + next_group(&mut subscription) + .await + .unwrap() + .expect("the catalog") + .sequence, + 0 + ); + assert!(next_group(&mut subscription).await.unwrap().is_none()); + drop(subscription); + assert!( + tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .is_err(), + "a finished track within the linger asked the source again" + ); + + // Paused time runs the front's earlier deadline before this sleep returns. + tokio::time::sleep(TRACK_IDLE_LINGER).await; + + let track = resolved.track("catalog").unwrap(); + let _subscribing = tokio::spawn(async move { track.subscribe(None).await }); + tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the finished track outlived the linger") + .expect("request"); + } + /// A group that stays open for good (a JSON log in group 0) survives a park: the /// returning reader gets the frames delivered before it from the warm cache, and the /// re-splice asks the source only for the frames after them, across repeated parks. @@ -5398,6 +6141,25 @@ mod tests { assert_eq!(request.path().as_str(), "room/chat"); } + #[tokio::test] + async fn scoped_cursor_selects_among_the_routes_it_can_see() { + let producer = origin(1).produce(); + let scoped = |pattern: &str| { + producer + .scope("", &Patterns::from(pattern.parse::().unwrap())) + .unwrap() + }; + let _chat = scoped("*/chat").dynamic("", Route::default().with_cost(1)).unwrap(); + let _video = scoped("*/video").dynamic("", Route::default().with_cost(5)).unwrap(); + + let mut video = producer + .consume() + .scope("", &scopes(&["room/video"])) + .unwrap() + .announced(); + assert_eq!(video.assert_next_active("").cost, Cost::new(5)); + } + #[tokio::test] async fn dynamic_accepts_a_max_depth_prefix() { let producer = origin(1).produce(); @@ -5634,6 +6396,92 @@ mod tests { assert_eq!(announced.assert_next_active("room/x").source(), Source::Peer(origin(8))); } + /// A peer withdrawing a prefix hides the routes there through it, so the next + /// best is never a path derived from the one withdrawn. Announcing again + /// revives them, and nothing is remembered once no route passes through it. + #[tokio::test] + async fn withdrawn_peer_hides_routes_through_it() { + let producer = origin(1).produce(); + let peer = producer.clone().peer(); + let mut announced = producer.consume().announced(); + + // Publisher 9 ingests at relay 2; relay 3 relays 2's route. + let direct = || Route::default().with_hops(hops(&[9, 2])).with_via(origin(2)); + let first = peer.dynamic("room", direct()).unwrap(); + let relayed = peer + .dynamic("room", Route::default().with_hops(hops(&[9, 2, 3])).with_via(origin(3))) + .unwrap(); + announced.assert_next_active("room"); + announced.assert_next_wait(); + + // Relay 2 withdraws: the relayed copy goes with it rather than taking over. + first.withdrawn(); + announced.assert_next_ended("room"); + announced.assert_next_wait(); + + // Relay 2 announces again, and the relayed copy is live with it. + let second = peer.dynamic("room", direct()).unwrap(); + announced.assert_next_active("room"); + assert!(producer.shared.lock().withdrawn.is_empty()); + + // Nothing passes through relay 2 once both routes are gone. + second.withdrawn(); + drop(relayed); + announced.assert_next_ended("room"); + assert!(producer.shared.lock().withdrawn.is_empty()); + } + + /// Overlapping sessions are independent claims, regardless of announcement order. + #[tokio::test] + async fn old_session_withdrawal_keeps_newer_route() { + for restart in [false, true] { + let producer = origin(1).produce(); + let peer = producer.clone().peer(); + let mut announced = producer.consume().announced(); + let route = Route::default().with_hops(hops(&[9, 2])); + let first = peer.dynamic("room", route.clone()).unwrap(); + let second = peer.dynamic("room", route.clone()).unwrap(); + announced.assert_next_active("room"); + announced.assert_next_wait(); + let remaining = if restart { + // The older table entry has the newest advertisement after a restart. + first.update(route).unwrap(); + second.withdrawn(); + first + } else { + first.withdrawn(); + second + }; + announced.assert_next_wait(); + assert!(producer.shared.lock().withdrawn.is_empty()); + remaining.withdrawn(); + announced.assert_next_ended("room"); + assert!(producer.shared.lock().withdrawn.is_empty()); + } + } + + #[tokio::test] + async fn withdrawn_route_no_longer_covers_requests() { + let producer = origin(1).produce(); + let peer = producer.clone().peer(); + let direct = peer.dynamic("room", Route::default().with_hops(hops(&[9, 2]))).unwrap(); + let _relayed = peer + .dynamic("room", Route::default().with_hops(hops(&[9, 2, 3]))) + .unwrap(); + let path = Path::new("room/video"); + let id = producer + .shared + .read() + .routes + .covering(&path) + .find(|entry| entry.hops.iter().last() == Some(&origin(3))) + .unwrap() + .id; + assert!(producer.shared.read().routes.covers(&path, id)); + direct.withdrawn(); + assert!(!producer.shared.read().routes.covers(&path, id)); + } + /// A change of source alone is delivered: the same chain and cost arriving /// from a peer instead of a client is a different fact for the consumer. #[tokio::test] @@ -6486,6 +7334,27 @@ mod tests { assert!(end.is_none(), "a group followed the final one"); } + /// A standing route outlives the source it produced: the front ends instead of + /// asking that route for the broadcast that just closed. + #[tokio::test] + async fn a_closed_source_is_not_requested_again_from_its_standing_route() { + let producer = origin(1).produce(); + let server = producer + .dynamic("room", Route::default().with_hops(hops(&[10]))) + .unwrap(); + let pending = producer.consume().request_broadcast("room/alice"); + let source = broadcast::Info::new().produce(); + queued(&server).await.accept(&source); + let resolved = pending.await.unwrap(); + + source.close(); + settle(|| resolved.is_closed()).await; + assert!( + server.poll_requested_broadcast(&kio::Waiter::noop()).is_pending(), + "the closed source was requested again" + ); + } + /// An origin front drops the source track as soon as its last reader leaves, /// so the publisher's `unused()` resolves far below `TRACK_IDLE_LINGER`. /// Cached groups stay on the front for the linger; a returning reader diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 8c9b9eb0f6..66360e9d46 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -518,6 +518,11 @@ impl Producer { self.state.read().abort.is_some() } + /// Whether `other` produces the same logical track. + pub(crate) fn is_clone(&self, other: &Self) -> bool { + self.state.same_channel(&other.state) + } + /// Create a read handle for the logical track. pub fn consume(&self) -> Consumer { Consumer { @@ -2256,6 +2261,26 @@ impl Subscriber { } } + /// Poll for where the source of the first live segment reaching this cursor's floor + /// starts, raised to the floor, once resolved; see [`track::Subscriber::poll_start`]. + /// A segment ending at or below the floor, or one that already ended, serves nothing + /// more here, so its source is not waited on. Like [`Self::poll_final`], this only + /// resolves the subscription and consumes no groups. + pub(crate) fn poll_start(&mut self, waiter: &kio::Waiter) -> Poll> { + self.poll_sync(waiter); + let floor = self.min_sequence; + for seg in &mut self.segments { + if seg.last_group().is_some_and(|last| last <= floor) { + continue; + } + ready!(Self::poll_activate(seg, &self.last_prefs, floor, waiter)); + if let SubState::Active(sub) = &mut seg.sub { + return Poll::Ready(ready!(sub.poll_start(waiter)).map(|start| start.max(floor))); + } + } + Poll::Ready(None) + } + /// Wait for the final segment's track to end: its group count when it /// finished, its error when it died, and `None` when there is no segment. Earlier segments don't /// decide the end. Only the subscription is resolved here: consuming groups, or @@ -2483,6 +2508,58 @@ mod test { recv_pending(&mut sub); } + /// A segment that ends at or below the cursor's floor serves it nothing, so its + /// source's unresolved start must not hold up the start of the segment that does. + #[tokio::test] + async fn poll_start_skips_a_segment_below_the_floor() { + let (mut track_a, consumer_a) = track_pair("a"); + let (mut track_b, consumer_b) = track_pair("b"); + track_a.request_start(Some(0)).unwrap(); + track_b.request_start(Some(5)).unwrap(); + + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + producer.switch(&consumer_b, Position::group(5)).unwrap(); + + let mut sub = producer.consume().subscribe(replay()); + sub.start_at(10); + assert!( + kio::wait(|waiter| sub.poll_start(waiter)).now_or_never().is_none(), + "B's source has not resolved yet" + ); + + // A's source never resolves: it serves nothing at or above the floor. + track_b.start_at(12).unwrap(); + let start = kio::wait(|waiter| sub.poll_start(waiter)).now_or_never(); + assert_eq!(start, Some(Some(12)), "waited on a segment below the floor"); + } + + /// A segment that already ended serves nothing more, so the start comes from the next + /// one rather than falling back to whichever group arrives first. + #[tokio::test] + async fn poll_start_skips_an_ended_segment() { + let (track_a, consumer_a) = track_pair("a"); + let (mut track_b, consumer_b) = track_pair("b"); + track_b.request_start(Some(5)).unwrap(); + + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + producer.switch(&consumer_b, Position::group(5)).unwrap(); + track_a.abort(Error::Cancel).unwrap(); + + let mut sub = producer.consume().subscribe(replay()); + // Reading drains A to its end, leaving nothing for it to serve. + recv_pending(&mut sub); + assert!( + kio::wait(|waiter| sub.poll_start(waiter)).now_or_never().is_none(), + "B's source has not resolved yet" + ); + + track_b.start_at(5).unwrap(); + let start = kio::wait(|waiter| sub.poll_start(waiter)).now_or_never(); + assert_eq!(start, Some(Some(5))); + } + #[tokio::test] async fn demand_reflects_boundaries() { let (track_a, consumer_a) = track_pair("a"); diff --git a/rs/moq-net/src/model/timed.rs b/rs/moq-net/src/model/timed.rs new file mode 100644 index 0000000000..b146ed2723 --- /dev/null +++ b/rs/moq-net/src/model/timed.rs @@ -0,0 +1,60 @@ +use bytes::Bytes; + +use crate::Timestamp; + +/// A value to publish, and optionally when it was captured. +/// +/// `T` is the clock the time is on: a raw [`Timestamp`] by default, or whatever a higher layer +/// maps onto one (`moq-mux` takes a [`std::time::Instant`]). Converts from a bare payload (bytes, +/// or a `&V` to serialize), so a producer taking one still accepts the plain value, stamped when +/// written. +#[derive(Debug, Clone, Copy)] +pub struct Timed { + /// The value to publish. + pub value: P, + + /// When the value was captured. `None` stamps it when written. + pub at: Option, +} + +impl Timed { + /// Set when the value was captured. + pub fn at(mut self, at: T) -> Self { + self.at = Some(at); + self + } +} + +impl, T> From for Timed { + fn from(value: B) -> Self { + Self { + value: value.into(), + at: None, + } + } +} + +impl<'a, V, T> From<&'a V> for Timed<&'a V, T> { + fn from(value: &'a V) -> Self { + Self { value, at: None } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_payload_converts_untimed() { + let timed: Timed = vec![1u8, 2].into(); + assert_eq!(timed.value, Bytes::from_static(&[1, 2])); + assert_eq!(timed.at, None); + + let stamped = Timed::from(&b"x"[..]).at(Timestamp::from_millis(5).unwrap()); + assert_eq!(stamped.at, Some(Timestamp::from_millis(5).unwrap())); + + let value = 7u32; + let timed: Timed<&u32, std::time::Instant> = (&value).into(); + assert_eq!(*timed.value, 7); + } +} diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index f622404c57..9fc80e0115 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -196,9 +196,10 @@ pub(crate) struct TrackState { max_sequence: Option, // The sequence of the newest cached group: the live edge, protected from - // eviction by never entering the eviction order. Tracked separately from - // `max_sequence` because datagrams advance that shared counter, and the live - // edge must still demote correctly when the next group lands past one. + // eviction by never entering the eviction order until the track is `closed`. + // Tracked separately from `max_sequence` because datagrams advance that shared + // counter, and the live edge must still demote correctly when the next group + // lands past one. latest_group: Option, // Incarnation counter for `Slot::stamp`. @@ -211,6 +212,11 @@ pub(crate) struct TrackState { // below it will never be produced. sealed: bool, + // No producer remains (aborted, sealed, or dropped), so nothing protects the live + // edge any longer: the pool's idle expiry reclaims it like every other group, and a + // stale consumer cannot pin the cache. + closed: bool, + // The first sequence the live feed serves, once the publisher declared one // (the wire's SUBSCRIBE_START). Lower groups never arrive on their own; a // fetch can still create them. @@ -222,7 +228,7 @@ pub(crate) struct TrackState { // [`Consumer::poll_start`]) wait on it; everything else treats the floor as usual. start_pending: bool, - // Where production stopped, snapshotted when the cached groups are released (an + // Where production stopped, snapshotted when the open groups are released (an // abort, or the last producer dropping). Computed live from the cache otherwise; // see [`Self::resume_position`]. resume: Option, @@ -395,15 +401,26 @@ impl TrackState { next_sequence: u64, end_sequence: Option, ) -> Poll>> { + // Nothing more can arrive once the track was sealed (dropped with its end + // declared) or aborted. Either closed the channel, so parking is not an option: + // the consumer would be handed the abort, or `Dropped`, instead of how it ended. + let closed = self.sealed || self.abort.is_some(); + // Once nothing more is in range: an abort before the end settled cut the track + // off. One after it is a clean end (see `is_complete`), as is reaching the end. + let end = || match &self.abort { + Some(err) if !self.settled => Err(err.clone()), + _ => Ok(None), + }; + // If the exclusive end is already at or below where we'd resume, no // group can ever satisfy this call until the cap rises. Pending (not // None) so the consumer is parked rather than told the stream is over. // An empty range (`end == 0`) parks even at the first sequence. - if let Some(end) = end_sequence - && end <= next_sequence + if let Some(cap) = end_sequence + && cap <= next_sequence { - if let Some(err) = &self.abort { - return Poll::Ready(Err(err.clone())); + if closed { + return Poll::Ready(end()); } return Poll::Pending; } @@ -424,17 +441,14 @@ impl TrackState { } // No in-range group is cached. Decide whether more could ever arrive. - if let Some(err) = &self.abort { - return Poll::Ready(Err(err.clone())); - } // `final_sequence` is one past the last possible sequence. If our // floor is already at/past it, nothing else can land in range. - // A sealed track produces nothing more either: the last producer - // dropped with the boundary already declared, so a gap below it is - // the end, not a wait. Cached in-range groups were returned above. + // A closed track produces nothing more either, so a gap below its + // boundary is the end, not a wait. Cached in-range groups were + // returned above, so an aborted track drains what finished first. // This is the cursor the ordered and spliced readers use. - if self.sealed || self.final_sequence.is_some_and(|fin| next_sequence >= fin) { - return Poll::Ready(Ok(None)); + if closed || self.final_sequence.is_some_and(|fin| next_sequence >= fin) { + return Poll::Ready(end()); } Poll::Pending } @@ -711,7 +725,7 @@ impl TrackState { continue; } if slot.group.is_aborted() - || (Some(sequence) != self.latest_group + || (!self.protects(sequence) && slot .group .cache_accessed_tick(scan.gc.then_some(scan.now)) @@ -736,6 +750,26 @@ impl TrackState { || self.evict.len() > 2 * self.lookup.len() + EVICT_SLACK } + /// Expire an ended track's idle groups, whose closed channel refuses the write + /// [`Self::evict_expired_scan`] takes: abort them in place, releasing their frames, + /// and leave the slots, which every read path already skips. + pub(super) fn expire_closed(&self, scan: ExpiryScan) { + for (sequence, stamp) in &self.evict { + let Some(slot) = self.lookup.get(sequence) else { + continue; + }; + if slot.stamp == *stamp + && !slot.group.is_aborted() + && slot + .group + .cache_accessed_tick(scan.gc.then_some(scan.now)) + .is_some_and(|tick| scan.now.saturating_sub(tick) > scan.max_ticks) + { + let _ = slot.group.clone().abort(Error::Old); + } + } + } + /// Apply a scan previously selected by [`Self::expiry_scan`] or /// [`Self::expiry_scan_drain`]. pub(super) fn evict_expired_scan(&mut self, scan: ExpiryScan) { @@ -758,7 +792,7 @@ impl TrackState { self.lookup.remove(&sequence); continue; } - if Some(sequence) == self.latest_group + if self.protects(sequence) || slot .group .cache_accessed_tick(scan.gc.then_some(scan.now)) @@ -812,14 +846,32 @@ impl TrackState { self.lookup.get(&sequence).is_some_and(|slot| slot.stamp == stamp) } - /// Drop every cached group and reset the eviction bookkeeping. Each group's - /// access sample lives in its own charge, released when the group itself dies. - fn clear_cache(&mut self) { - self.lookup.clear(); - self.arrival.clear(); - self.evict.clear(); - self.latest_group = None; - self.debt = 0; + /// Whether `sequence` is the live edge, which eviction and expiry never take + /// while a producer remains. + fn protects(&self, sequence: u64) -> bool { + !self.closed && Some(sequence) == self.latest_group + } + + /// No producer remains: stop protecting the live edge, so the pool's idle expiry + /// reclaims every group once readers stop touching it, and a stale consumer can't + /// pin the cache (and its frame buffers) forever. + fn close_cache(&mut self) { + if std::mem::replace(&mut self.closed, true) { + return; + } + if let Some(latest) = self.latest_group + && let Some(slot) = self.lookup.get(&latest) + { + slot.group.cache_demote(); + self.evict.push_back((latest, slot.stamp)); + } + } + + /// Drop the open groups nobody will finish now that the track ended abruptly, and + /// keep the finished ones for readers still draining. A consumer that already + /// pulled an open group keeps its own handle and ends with it. + fn drop_open_groups(&mut self) { + self.lookup.retain(|_, slot| slot.group.is_finished()); } /// Attach the publisher's immutable metadata without replacing it with local cache policy. @@ -893,7 +945,8 @@ impl TrackState { self.evict.push_back((latest, prev.stamp)); } self.latest_group = Some(sequence); - } else { + } + if !self.protects(sequence) { group.cache_demote(); self.evict.push_back((sequence, stamp)); } @@ -1006,7 +1059,7 @@ impl TrackState { self.lookup.remove(&sequence); continue; } - if Some(sequence) == self.latest_group { + if self.protects(sequence) { // The live edge is never enqueued, but tolerate finding it anyway. self.evict.push_back((sequence, stamp)); continue; @@ -1092,8 +1145,8 @@ impl TrackState { /// /// `None` while the track has produced nothing, which is an unbounded takeover. fn resume_position(&self) -> Option { - // A snapshot taken when the cache was released wins; the groups it was derived - // from are gone. + // A snapshot taken when the open groups were released wins; the group it was + // derived from may be gone. if self.resume.is_some() { return self.resume; } @@ -1173,14 +1226,17 @@ impl TrackState { /// Record `err` and close the track: the shared tail of [`Producer::abort`] and /// [`Producer::abort_unused`]. fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) { - // Snapshot the frame boundary before the cache it's derived from goes away: an + // Snapshot the frame boundary before the open group it may sit in goes away: an // abort is exactly when a replacement route asks where to resume. state.resume = state.resume_position(); - // Decided before the cache goes: the groups below the end are the evidence. + // Decided before the open groups go: the groups below the end are the evidence. state.settled = state.is_settled(); state.abort = Some(err); - state.clear_cache(); - state.datagrams.clear(); + // Keep what finished for consumers still draining: they get it, then the abort (or + // the clean end, when the end settled). Clearing here would abort finished groups a + // slower reader has not pulled yet. The pool's idle expiry bounds how long they stay. + state.drop_open_groups(); + state.close_cache(); state.close(); } @@ -1484,11 +1540,15 @@ impl Producer { /// Abort the track with the given error. /// - /// Consumes the handle, since nothing can be written to an aborted track. Drops the - /// cached groups so a stale [`Consumer`] can't pin them (and their frame buffers) in - /// memory forever. Consumers that haven't drained yet surface the abort error instead - /// of the leftover cache. Child groups are independent: a consumer that already pulled - /// a [`group::Consumer`] keeps its own handle and can finish reading it. + /// Consumes the handle, since nothing can be written to an aborted track. Consumers + /// still draining get the finished groups, then the abort error. Open groups leave the + /// cache; a consumer that already pulled one keeps its own handle and ends with it. + /// The pool's idle expiry (see [`cache::Config::with_expiry`]) reclaims the rest, the + /// latest group included, so a stale [`Consumer`] can't pin them in memory forever. + /// + /// If the declared end had settled (the final sequence from + /// [`finish_at`](Self::finish_at) was reached and every group below it finished), + /// consumers get a clean end instead of the error, as after [`finish`](Self::finish). /// /// [`finish`](Self::finish) is deliberately not terminal: it declares the final /// sequence, and lower-numbered groups may still be written afterwards. @@ -1904,10 +1964,10 @@ impl Drop for Alive { if !self.published.load(Ordering::Relaxed) { return; } - // The last producer going away without finishing is an abrupt teardown: - // release the cached groups so a stale consumer can't pin them (and their - // frame buffers) forever, the same as an explicit abort. A cleanly - // finished track keeps its cache so consumers can still drain it. + // The last producer going away ends the track: nothing protects its live edge + // anymore, so the pool's idle expiry reclaims what a stale consumer would pin. + // Without a finish it is an abrupt teardown, the same as an explicit abort: + // the open groups go and the finished ones stay for readers still draining. // `abort()` closes the channel, so `write()` returns `Err(Ref)`. `finish()` // leaves it open with `final_sequence` set, so inspect both outcomes. match self.state.write() { @@ -1916,6 +1976,7 @@ impl Drop for Alive { // Groups still missing below the boundary can no longer arrive, so a // reader waiting on one ends cleanly instead of with `Dropped`. state.sealed = true; + state.close_cache(); return; } if state.abort.is_some() { @@ -1925,10 +1986,10 @@ impl Drop for Alive { track = %self.name, "track::Producer dropped without finish() or abort()" ); - // See `abort`: keep the frame boundary once its groups go away. + // See `abort`: keep the frame boundary once its open group goes away. state.resume = state.resume_position(); - state.clear_cache(); - state.datagrams.clear(); + state.drop_open_groups(); + state.close_cache(); } Err(state) => { if state.final_sequence.is_some() || state.abort.is_some() { @@ -2404,21 +2465,23 @@ impl Consumer { /// whatever it last declared, since nothing will resolve it anymore. pub(crate) fn poll_start(&self, waiter: &kio::Waiter) -> Poll> { match &self.inner { - ConsumerKind::Plain(state) => { - let res = state.poll(waiter, |state| match state.start_pending && state.abort.is_none() { - true => Poll::Pending, - false => Poll::Ready(state.start_sequence), - }); - match res { - Poll::Ready(Ok(start)) => Poll::Ready(start), - Poll::Ready(Err(state)) => Poll::Ready(state.start_sequence), - Poll::Pending => Poll::Pending, - } - } + ConsumerKind::Plain(state) => Self::poll_state_start(state, waiter), ConsumerKind::Spliced(_) => Poll::Ready(None), } } + /// [`Self::poll_start`] over one track's state, shared with [`Subscriber::poll_start`]. + fn poll_state_start(state: &kio::Consumer, waiter: &kio::Waiter) -> Poll> { + let res = state.poll(waiter, |state| match state.start_pending && state.abort.is_none() { + true => Poll::Pending, + false => Poll::Ready(state.start_sequence), + }); + match ready!(res) { + Ok(start) => Poll::Ready(start), + Err(state) => Poll::Ready(state.start_sequence), + } + } + /// The newest group, when it is already cached: resolved synchronously, without /// counting as a fetch or a delivery. The IETF publisher snapshots its frame count to /// resolve Largest Object; a group that is not immediately available reads as no edge. @@ -3920,6 +3983,19 @@ impl Subscriber { } } + /// Poll for where the source's feed starts, raised to this cursor's floor, once + /// resolved; see [`Consumer::poll_start`]. A feed starting below the floor serves the + /// floor's group too. `None` when the source declares none. + pub(crate) fn poll_start(&mut self, waiter: &kio::Waiter) -> Poll> { + match &mut self.inner { + SubscriberKind::Plain(plain) => { + let start = ready!(Consumer::poll_state_start(&plain.state, waiter)); + Poll::Ready(start.map(|start| start.max(plain.min_sequence))) + } + SubscriberKind::Spliced(spliced) => spliced.poll_start(waiter), + } + } + /// Poll for the track's declared final sequence, without blocking. pub fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll> { match &mut self.inner { @@ -4091,8 +4167,9 @@ impl Ordered { /// the cap rises or is removed. /// /// Returns `Poll::Ready(Ok(Some(group)))` when a group is available, - /// `Poll::Ready(Ok(None))` when the track is finished, - /// `Poll::Ready(Err(e))` when the track has been aborted, or + /// `Poll::Ready(Ok(None))` when the track is finished (including an abort after its + /// declared end settled, see [`Producer::abort`]), + /// `Poll::Ready(Err(e))` when the track has been aborted short of its end, or /// `Poll::Pending` when no group is available yet. pub fn poll_next_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.inner.poll_next_group(waiter) @@ -6660,25 +6737,21 @@ mod test { } #[tokio::test] - async fn abort_clears_cached_groups() { + async fn abort_drops_open_groups() { let producer = track_producer("test", None); producer.append_group().unwrap(); producer.append_group().unwrap(); - // A stale consumer that never drains must not pin the cached groups. let mut consumer = producer.subscribe(None); assert_eq!(live_groups(&producer.state.read()), 2); producer.clone().abort(Error::Cancel).unwrap(); - { - let state = producer.state.read(); - assert!(state.lookup.is_empty(), "cached groups should be dropped on abort"); - assert!(state.arrival.is_empty()); - assert!(state.evict.is_empty()); - } - - // The consumer now surfaces the abort error rather than the leftover cache. + // Nobody will finish them, so they leave the cache rather than park a reader. + assert!( + producer.state.read().lookup.is_empty(), + "open groups are dropped on abort" + ); let result = consumer.recv_group().now_or_never().expect("should not block"); assert!(matches!(result, Err(Error::Cancel))); } @@ -6923,6 +6996,93 @@ mod test { assert!(matches!(res, Err(Error::Timeout))); } + /// An abort short of the end keeps the groups that finished for a reader that has not + /// pulled them yet: it gets them, then the abort. The open group nobody will finish + /// is gone, on both cursors. + #[tokio::test] + async fn abort_keeps_finished_groups_for_a_slow_reader() { + let producer = track_producer("test", None); + let mut arrival = producer.subscribe(None); + let mut ordered = producer.subscribe(None).ordered(); + + for sequence in 0..2 { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + let _open = producer.create_group(group::Info { sequence: 2 }).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + assert_eq!(arrival.assert_group().sequence, 0); + assert_eq!(arrival.assert_group().sequence, 1); + let res = arrival.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Err(Error::Timeout)), "expected the abort"); + + assert_eq!(drain_ordered(&mut ordered), [0, 1]); + let res = ordered + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!(matches!(res, Err(Error::Timeout)), "expected the abort, got {res:?}"); + } + + /// The last producer dropping without a finish keeps the finished groups the same way. + #[tokio::test] + async fn dropped_producer_keeps_finished_groups_for_a_slow_reader() { + let producer = track_producer("test", None); + let mut consumer = producer.subscribe(None); + producer + .create_group(group::Info { sequence: 0 }) + .unwrap() + .finish() + .unwrap(); + drop(producer); + + assert_eq!(consumer.assert_group().sequence, 0); + let res = consumer.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Err(Error::Dropped)), "expected the drop"); + } + + /// A closed track's groups, its latest included, expire once idle, so a stale + /// consumer cannot pin them. A live track's latest stays protected. + #[tokio::test] + async fn closed_track_expires_its_latest_group() { + let pool = cache::Pool::new(cache::Config::default().with_expiry(Duration::from_secs(1))); + + let live = track_producer_pooled("live", pool.clone()); + live.append_group().unwrap().finish().unwrap(); + + let aborted = track_producer_pooled("aborted", pool.clone()); + aborted.append_group().unwrap().finish().unwrap(); + let stale_aborted = aborted.consume(); + aborted.abort(Error::Timeout).unwrap(); + + let finished = track_producer_pooled("finished", pool.clone()); + finished.append_group().unwrap().finish().unwrap(); + finished.finish().unwrap(); + let stale_finished = finished.consume(); + drop(finished); + + // The first pass dates the activity it has not seen yet; the next one expires it. + for _ in 0..2 { + crate::model::clock::advance(Duration::from_secs(2)); + pool.sweep(); + } + + assert!(live.consume().peek_group(0).is_some(), "a live track keeps its latest"); + assert!( + stale_aborted.peek_group(0).is_none(), + "an aborted track's latest expired" + ); + assert!( + stale_finished.peek_group(0).is_none(), + "a sealed track's latest expired" + ); + } + /// An abort after every group below the declared end finished leaves the end standing. #[tokio::test] async fn abort_after_the_end_settles_ends_clean() { @@ -6942,6 +7102,83 @@ mod test { assert!(matches!(res, Ok(None))); } + /// The settled end stands on the ordered cursor too: a reader that starts after the + /// abort gets every group below the end, then the clean end, not the abort. + #[tokio::test] + async fn abort_after_the_end_settles_ends_clean_for_an_ordered_reader() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None).ordered(); + + for sequence in 0..2 { + let group = producer.create_group(group::Info { sequence }).unwrap(); + group.finish().unwrap(); + } + producer.finish_at(2).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + assert_eq!(drain_ordered(&mut consumer), [0, 1]); + let end = consumer + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!(matches!(end, Ok(None)), "expected the clean end, got {end:?}"); + } + + /// The settled end stands on the ordered cursor even where it has nothing to read: + /// capped below the end, and with a gap below the cap. Parking is not an option on a + /// closed track (the channel would turn it into the abort), so these end clean. + #[tokio::test] + async fn abort_after_the_end_settles_ends_clean_below_the_cap() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None).ordered(); + for sequence in 0..2 { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + producer.finish_at(2).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + consumer.set_groups(..1); + assert_eq!(drain_ordered(&mut consumer), [0]); + let end = consumer + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!( + matches!(end, Ok(None)), + "capped at the end: expected the clean end, got {end:?}" + ); + + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None).ordered(); + for sequence in [0, 2] { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + producer.finish_at(3).unwrap(); + producer.abort(Error::Timeout).unwrap(); + + consumer.set_groups(..2); + assert_eq!(drain_ordered(&mut consumer), [0]); + let end = consumer + .next_group() + .now_or_never() + .expect("should not block") + .map(|group| group.map(|group| group.sequence)); + assert!( + matches!(end, Ok(None)), + "gap below the cap: expected the clean end, got {end:?}" + ); + } + #[tokio::test] async fn recv_group_finishes_without_waiting_for_gaps() { let producer = track_producer("test", None); @@ -8200,8 +8437,13 @@ mod test { /// surviving publisher, or an abrupt drop silently behaves like a clean finish. #[tokio::test] async fn teardown_ignores_a_settling_group() { - let (mut producer, pool) = pooled_producer(1 << 40); - finished_group(&mut producer, 100); + let (producer, pool) = pooled_producer(1 << 40); + // Open, so only the abrupt teardown releases it. + let mut group = producer.append_group().unwrap(); + group + .write_frame(Timestamp::ZERO, bytes::Bytes::from(vec![0u8; 100])) + .unwrap(); + drop(group); // Stand in for a concurrent `cache::Track::settle`, mid-upgrade. let settling = producer.state.downgrade().upgrade().expect("open"); diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 76affbe609..6a35f32394 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -151,7 +151,17 @@ impl Server { /// which is what drops the thread-affinity bounds: a pinned `!Send` /// transport can gate on the advertised path too. Anything but a moq-lite /// ALPN is refused with [`Error::Version`]. - pub async fn accept_request_lite(&self, now: Instant, mut session: S) -> Result, Error> + pub async fn accept_request_lite(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Session, + { + let mut refused = session.clone(); + self.handshake_lite(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake_lite(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Session, { @@ -215,6 +225,8 @@ impl Server { path, role, origin, + // moq-lite carries no SETUP token. + token: None, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -250,7 +262,22 @@ impl Server { /// /// The path is surfaced for moq-lite-05 and newer, and every moq-transport /// draft we speak; it's empty on versions with no in-band request path (lite 01-04). - pub async fn accept_request(&self, now: Instant, mut session: S) -> Result, Error> + /// + /// A SETUP that fails to parse or negotiate closes the session with the matching code, + /// so the peer learns why instead of seeing a bare disconnect. + pub async fn accept_request(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Boxable, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, + { + let mut refused = session.clone(); + self.handshake(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, @@ -295,7 +322,7 @@ impl Server { // Every lite ALPN goes through the same entry point, which is also // what a `!Send` transport calls directly. Some(ALPN_LITE_07_WIP | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03) => { - return self.accept_request_lite(now, session).await; + return self.handshake_lite(now, session).await; } Some(ALPN_LITE) | None => { let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; @@ -319,7 +346,7 @@ impl Server { // Pull the request path and max request ID out now (IETF only) so `ok()` // doesn't re-decode the consumed parameters. moq-transport carries the path // in its SETUP just like lite-05. - let (path, request_id_max, peer_declared) = match version { + let (path, token, request_id_max, peer_declared) = match version { Version::Ietf(v) => { let params = ietf::Parameters::decode(&mut client.parameters, v)?; let path = match params.get_bytes(ietf::ParameterBytes::Path) { @@ -330,6 +357,7 @@ impl Server { ), None => None, }; + let token = ietf::token::from_setup(¶ms, v)?; let request_id_max = params .get_varint(ietf::ParameterVarInt::MaxRequestId) .map(ietf::RequestId); @@ -339,15 +367,16 @@ impl Server { active_count: ietf::active_count::from_setup(¶ms, v), ..Default::default() }; - (path, request_id_max, peer_declared) + (path, token, request_id_max, peer_declared) } - Version::Lite(_) => (None, None, ietf::peer::Peer::default()), + Version::Lite(_) => (None, None, None, ietf::peer::Peer::default()), }; Ok(Handshake { path, role: None, origin: None, + token, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -383,6 +412,7 @@ impl Server { // A moq-transport peer only has an identity if it negotiated the MoQ // Cluster extension and declared a non-zero Hop ID. origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), + token: peer_setup.token.clone(), assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -408,6 +438,7 @@ pub struct Handshake { path: Option, role: Option, origin: Option, + token: Option, /// The identity this session's routes are stamped with when the peer declares none /// on the wire. Fresh per request unless the caller overrides it /// ([`Handshake::with_peer_hop`]). @@ -517,9 +548,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -621,9 +651,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -665,6 +694,14 @@ where self.origin } + /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. + /// + /// Only moq-transport carries one, so moq-lite sessions return `None`. The transport + /// has not verified it: authorize on it the way you would a URL token. + pub fn token(&self) -> Option<&setup::Token> { + self.token.as_ref() + } + /// Publish to the connected client. Overrides any value from the [`Server`] /// builder; typically set after inspecting [`path`](Self::path). pub fn with_publisher(mut self, publish: impl Consume) -> Self { @@ -744,10 +781,15 @@ impl RequestInner { PausedHandshake::LiteSetup { session, .. } => session, PausedHandshake::Boxed(paused) => return paused.close(err), }; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + close(&mut session, &err); } } +/// Close `session` with `err`'s wire code. +fn close(session: &mut S, err: &Error) { + session.close(SessionError::from(err).to_code(), &err.to_string()); +} + impl Drop for Handshake { // A dropped request would otherwise leave the client hanging until its idle // timeout: it already sent SETUP and is waiting on a response. Reject loudly. @@ -791,12 +833,15 @@ mod tests { } } - /// A session that replays a queue of unidirectional streams (each a `Vec`) in - /// order from `accept_uni`; everything else is inert. + /// A session that replays a queue of streams (each a `Vec`) in order from + /// `accept_uni` and `accept_bi`, and records the code it was closed with; everything + /// else is inert. #[derive(Clone)] struct FakeSession { protocol: Option<&'static str>, uni: Arc>>>, + bi: Arc>>>, + closed: Arc>>, } impl FakeSession { @@ -804,8 +849,19 @@ mod tests { Self { protocol: Some(protocol), uni: Arc::new(Mutex::new(uni.into_iter().collect())), + bi: Default::default(), + closed: Default::default(), } } + + fn with_bi(self, bi: Vec) -> Self { + self.bi.lock().unwrap().push_back(bi); + self + } + + fn closed(&self) -> Option { + *self.closed.lock().unwrap() + } } impl web_transport_trait::poll::Session for FakeSession { @@ -826,7 +882,10 @@ mod tests { &mut self, _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - std::task::Poll::Pending + match self.bi.lock().unwrap().pop_front() { + Some(data) => std::task::Poll::Ready(Ok((FakeSend, FakeRecv { data: data.into() }))), + None => std::task::Poll::Pending, + } } fn poll_open_bi( &mut self, @@ -859,7 +918,9 @@ mod tests { fn protocol(&self) -> Option<&str> { self.protocol } - fn close(&mut self, _code: u32, _reason: &str) {} + fn close(&mut self, code: u32, _reason: &str) { + self.closed.lock().unwrap().get_or_insert(code); + } fn poll_closed(&mut self, _cx: &mut std::task::Context<'_>) -> std::task::Poll { std::task::Poll::Pending } @@ -938,6 +999,10 @@ mod tests { if let Some(path) = path { params.set_bytes(ietf::ParameterBytes::Path, path.as_bytes().to_vec()); } + ietf_setup_with(version, params) + } + + fn ietf_setup_with(version: ietf::Version, params: ietf::Parameters) -> Vec { let parameters = params.encode_bytes(version).unwrap(); let mut buf = Vec::new(); @@ -947,6 +1012,91 @@ mod tests { buf } + /// Encode a draft 14-16 CLIENT_SETUP, sent on the control bidi stream. + fn legacy_setup(version: ietf::Version, params: ietf::Parameters) -> Vec { + let mut buf = Vec::new(); + setup::Client { + versions: crate::coding::Versions::from([crate::Version::Ietf(version).into()]), + parameters: params.encode_bytes(version).unwrap(), + } + .encode(&mut buf, crate::Version::Ietf(version)) + .unwrap(); + buf + } + + fn setup_token() -> setup::Token { + setup::Token { + kind: setup::Token::OUT_OF_BAND, + value: vec![0x00, 0xff, b'j', b'w', b't'], + } + } + + fn token_params(version: ietf::Version) -> ietf::Parameters { + let mut params = ietf::Parameters::default(); + ietf::token::into_setup(&mut params, &setup_token(), version).unwrap(); + params + } + + #[tokio::test(start_paused = true)] + async fn accept_request_exposes_the_setup_token() { + let modern = FakeSession::new( + ALPN_19, + [ietf_setup_with( + ietf::Version::Draft19, + token_params(ietf::Version::Draft19), + )], + ); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup( + ietf::Version::Draft16, + token_params(ietf::Version::Draft16), + )); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), Some(&setup_token()), "{name}"); + } + } + + #[tokio::test(start_paused = true)] + async fn accept_request_without_a_token_reports_none() { + let ietf = FakeSession::new(ALPN_19, [ietf_setup(ietf::Version::Draft19, None)]); + let lite = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); + for (name, session) in [("draft-19", ietf), ("lite-05", lite)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), None, "{name}"); + } + } + + /// A SETUP the server refuses closes the session with the code naming why, on both + /// the draft-17+ uni stream and the draft 14-16 bidi stream. + #[tokio::test(start_paused = true)] + async fn a_refused_setup_token_closes_with_its_code() { + let delete = [0x0, 0x7]; // DELETE alias 7 + let truncated = [0x3]; // USE_VALUE with no Token Type + for (raw, code) in [ + (&delete[..], SessionError::ProtocolViolation), + (&truncated[..], SessionError::KeyValueFormatting), + ] { + let mut params = ietf::Parameters::default(); + params.set_bytes(ietf::ParameterBytes::AuthorizationToken, raw.to_vec()); + + let modern = FakeSession::new(ALPN_19, [ietf_setup_with(ietf::Version::Draft19, params.clone())]); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup(ietf::Version::Draft16, params)); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let result = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session.clone()) + .await; + assert!(result.is_err(), "{name}"); + assert_eq!(session.closed(), Some(code.to_code()), "{name} {code}"); + } + } + } + #[tokio::test(start_paused = true)] async fn accept_request_reads_ietf_path() { // Every draft-17+ version gates on the SETUP stream before starting, so the @@ -1077,6 +1227,7 @@ mod tests { path: None, role: None, origin: None, + token: None, assigned_hop: Hop::random(), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), @@ -1090,6 +1241,7 @@ mod tests { Version::Ietf(version), ), path: None, + token: None, declared: ietf::peer::Peer::default(), }, })), diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 7d0006ecf0..3a4a96cba0 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -1,3 +1,7 @@ +//! The SETUP exchange, and the credential a peer may present in it. +//! +//! Only [`Token`] is public; the SETUP messages themselves are wire internals. + use bytes::Bytes; use crate::{ @@ -12,9 +16,27 @@ const SERVER_SETUP: u8 = 0x21; /// Draft-17 unified SETUP message type (varint 0x2F00) pub(crate) const SETUP_V17: u64 = 0x2F00; +/// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. +/// +/// The transport never reads the bytes; verifying them is the application's job. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// The wire Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token itself. + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + pub const CAT: u64 = 0x1; +} + /// Draft-17+ unified SETUP message, with the same encoding for both client and server. #[derive(Debug, Clone)] -pub struct Setup { +pub(crate) struct Setup { pub parameters: Bytes, } @@ -88,7 +110,7 @@ impl SetupVersion { /// A version-agnostic setup message sent by the client. #[derive(Debug, Clone)] -pub struct Client { +pub(crate) struct Client { /// The list of supported versions in preferred order. pub versions: coding::Versions, @@ -171,7 +193,7 @@ impl Encode for Client { /// Sent by the server in response to a client setup. #[derive(Debug, Clone)] -pub struct Server { +pub(crate) struct Server { /// The list of supported versions in preferred order. pub version: coding::Version, diff --git a/rs/moq-net/tests/announce_covering_route.rs b/rs/moq-net/tests/announce_covering_route.rs new file mode 100644 index 0000000000..cfd1669f09 --- /dev/null +++ b/rs/moq-net/tests/announce_covering_route.rs @@ -0,0 +1,126 @@ +//! A peer asking for announcements below a route's prefix hears that route as +//! covering the prefix it asked for, the way a local consumer rooted there does. +//! +//! The route `.dash` serves every path beneath it, so a cursor rooted at +//! `.dash/nobody` sees it at its own root. Across a session the publisher must +//! translate it into the requested scope rather than end the session. + +mod support; + +use std::time::Duration; + +use moq_net::{Hop, Pattern, Patterns, Version, origin}; +use support::harness::{MockConnectOptions, connect_mock}; + +/// Long enough, in virtual time, for anything in flight to reach the far side. +const SETTLE: Duration = Duration::from_secs(1); + +const SERVED: &str = ".dash"; +const REQUESTED: &str = ".dash/nobody"; + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Drain every event the cursor has pending, as `kind prefix` lines, skipping the +/// live marker. +fn drain(announced: &mut moq_net::announce::Consumer) -> Vec { + use moq_net::announce::Event; + let mut seen = Vec::new(); + while let Some(event) = announced.try_next() { + let (kind, announce) = match event { + Event::Start(announce) => ("Start", announce), + Event::Update(announce) => ("Update", announce), + Event::End(announce) => ("End", announce), + Event::Live => continue, + }; + seen.push(format!("{kind} {}", announce.prefix)); + } + seen +} + +/// Announce `.dash`, then a path beneath the requested prefix, and record what a +/// cursor rooted at `.dash/nobody` sees, either on the publishing origin or on a +/// subscriber whose announce interest is that prefix. +async fn covering(version: Option<&str>) -> Vec { + let publisher = produce_origin(1); + let everything = Patterns::from(Pattern::all()); + + let (observer, pair) = match version { + None => (publisher.consume(), None), + Some(version) => { + let subscriber = produce_origin(2); + let interest = Patterns::from(Pattern::subtree(REQUESTED).unwrap()); + let scoped = subscriber.scope("", &interest).unwrap(); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(scoped); + (subscriber.consume(), Some(connect_mock(options).await)) + } + }; + let mut announced = observer.scope(REQUESTED, &everything).unwrap().announced(); + let mut log = Vec::new(); + + let served = publisher.create_broadcast(SERVED).unwrap(); + served.announce(Default::default()).unwrap(); + tokio::time::sleep(SETTLE).await; + log.push(format!("served: {:?}", drain(&mut announced))); + + let below = publisher.create_broadcast(format!("{REQUESTED}/cam")).unwrap(); + below.announce(Default::default()).unwrap(); + tokio::time::sleep(SETTLE).await; + log.push(format!("below: {:?}", drain(&mut announced))); + + if let Some(pair) = pair { + let closed = tokio::time::timeout(SETTLE, pair.client.closed()).await; + log.push(format!("session open: {}", closed.is_err())); + } + + log +} + +const EXPECTED: &[&str] = &["served: [\"Start \"]", "below: [\"Start cam\"]"]; + +#[tokio::test] +async fn local_cursor_sees_the_covering_route_at_its_root() { + tokio::time::pause(); + assert_eq!(covering(None).await, EXPECTED); +} + +#[tokio::test] +async fn remote_lite_peer_hears_the_covering_route() { + tokio::time::pause(); + let mut expected = EXPECTED.to_vec(); + expected.push("session open: true"); + for version in [ + "moq-lite-03", + "moq-lite-04", + "moq-lite-05", + "moq-lite-06", + "moq-lite-07-wip", + ] { + assert_eq!(covering(Some(version)).await, expected, "{version}"); + } +} + +#[tokio::test] +async fn remote_ietf_peer_hears_the_covering_route() { + tokio::time::pause(); + let mut expected = EXPECTED.to_vec(); + expected.push("session open: true"); + for version in [ + "moq-transport-14", + "moq-transport-15", + "moq-transport-16", + "moq-transport-17", + "moq-transport-18", + "moq-transport-19", + "moq-transport-20", + "moq-transport-21", + "moq-transport-22", + ] { + assert_eq!(covering(Some(version)).await, expected, "{version}"); + } +} diff --git a/rs/moq-net/tests/history_groups.rs b/rs/moq-net/tests/history_groups.rs new file mode 100644 index 0000000000..be515f4f94 --- /dev/null +++ b/rs/moq-net/tests/history_groups.rs @@ -0,0 +1,173 @@ +//! A fresh subscription from group 0 receives a finished older group alongside the open +//! newer one, however their streams race into a relay. +//! +//! A relay caches groups in upstream arrival order, and QUIC does not order streams, so +//! the newer group can land first. Resolving the relayed subscription's start from it +//! would drop the older group for good. The mock holds the publisher's group streams and +//! releases them newest first to make that race deterministic. + +mod support; + +use std::time::Duration; + +use moq_net::track::{Info, Position, Subscription}; +use moq_net::{Hop, Timestamp, Version}; +use support::harness::{MockConnectOptions, connect_mock}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// A budget no group outlives, on both the publisher and the subscriber. +const FOREVER: Duration = Duration::from_millis((1 << 53) - 1); + +const VERSIONS: &[&str] = &[ + "moq-lite-03", + "moq-lite-05", + "moq-lite-07-wip", + "moq-transport-14", + "moq-transport-17", + "moq-transport-22", +]; + +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 +} + +/// Publish a finished group 5 (three frames) then an open group 6 (two frames), optionally +/// through a relay and with their streams delivered newest first, and return each group's +/// sequence and the frames one subscriber read from it. +async fn round(version: &str, relay: bool, newest_first: bool) -> Vec<(u64, usize)> { + let version: Version = version.parse().unwrap(); + let publisher = produce_origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast + .create_track("history", Info::default().with_max_age(FOREVER)) + .unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let relay_origin = produce_origin(2); + let upstream = match relay { + true => { + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(relay_origin.clone()); + Some(connect_mock(options).await) + } + false => None, + }; + + let subscriber = produce_origin(3); + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(match relay { + true => relay_origin.consume(), + false => publisher.consume(), + }); + options.client_subscribe = Some(subscriber.clone()); + let pair = connect_mock(options).await; + + let consumer = subscriber.consume(); + tokio::time::timeout(TIMEOUT, consumer.routed("bcast")) + .await + .expect("announce timeout") + .expect("routed"); + let remote = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("bcast")) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + + let reader = tokio::spawn(async move { + let subscription = Subscription::default() + .with_max_age(FOREVER) + .with_start(Position::group(0)); + let mut sub = remote + .track("history") + .unwrap() + .subscribe(subscription) + .await + .expect("subscribe"); + + // Group 6 never ends, so read until every frame arrived or nothing more does. + let mut got = Vec::new(); + while got.iter().map(|(_, frames)| frames).sum::() < 5 { + let Ok(next) = tokio::time::timeout(TIMEOUT, sub.recv_group()).await else { + break; + }; + let Some(mut group) = next.expect("recv_group") else { + break; + }; + let mut frames = 0; + while let Ok(frame) = tokio::time::timeout(TIMEOUT, group.read_frame()).await { + match frame.expect("read_frame") { + Some(_) => frames += 1, + None => break, + } + } + got.push((group.sequence, frames)); + } + got.sort(); + got + }); + + tokio::time::timeout(TIMEOUT, track.used()) + .await + .expect("no subscriber appeared") + .unwrap(); + + // The hop the publisher's group streams cross first: into the relay, when there is one. + let first_hop = upstream + .as_ref() + .map_or(&pair.server_transport, |up| &up.server_transport); + if newest_first { + first_hop.hold_unis(); + } + + let mut old = track.create_group(moq_net::group::Info { sequence: 5 }).unwrap(); + for _ in 0..3 { + old.write_frame(Timestamp::now(), &b"old"[..]).unwrap(); + } + old.finish().unwrap(); + let mut live = track.create_group(moq_net::group::Info { sequence: 6 }).unwrap(); + for _ in 0..2 { + live.write_frame(Timestamp::now(), &b"live"[..]).unwrap(); + } + + if newest_first { + // Paused time advances only once every task is idle: both streams are open. + tokio::time::sleep(Duration::from_millis(10)).await; + first_hop.release_unis_reversed(); + } + + let got = tokio::time::timeout(TIMEOUT * 3, reader) + .await + .expect("reader hung") + .expect("reader panicked"); + drop(( + live, + track, + pair, + upstream, + relay_origin, + broadcast, + publisher, + subscriber, + )); + got +} + +#[tokio::test] +async fn a_fresh_subscriber_receives_the_finished_older_group() { + tokio::time::pause(); + let mut failures = Vec::new(); + for version in VERSIONS { + for relay in [false, true] { + for newest_first in [false, true] { + let got = round(version, relay, newest_first).await; + if got != [(5, 3), (6, 2)] { + failures.push(format!("{version} relay={relay} newest_first={newest_first}: {got:?}")); + } + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} diff --git a/rs/moq-net/tests/mesh_withdraw.rs b/rs/moq-net/tests/mesh_withdraw.rs new file mode 100644 index 0000000000..0f3ec7bb8d --- /dev/null +++ b/rs/moq-net/tests/mesh_withdraw.rs @@ -0,0 +1,153 @@ +//! Broadcasts withdrawn from a meshed cluster retract everywhere, once. +//! +//! Every relay holds a route through each peer that re-advertised a broadcast. +//! When the publisher's relay withdraws it, those routes all derive from the one +//! withdrawn; a relay that selects them in turn re-advertises each stale path, and +//! the cluster walks all of them before it converges (path hunting). + +mod support; + +use std::{collections::HashMap, time::Duration}; + +use moq_net::{Hop, Version, announce, broadcast, origin}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Peer two relays the way `moq-relay`'s cluster does: one session, both directions. +async fn peer(version: Version, a: &origin::Producer, b: &origin::Producer) -> MockPair { + let a = a.clone().peer(); + let b = b.clone().peer(); + let mut options = MockConnectOptions::new(version); + options.client_publish = Some(a.consume().with_hidden(true)); + options.client_subscribe = Some(a); + options.server_publish = Some(b.consume().with_hidden(true)); + options.server_subscribe = Some(b); + connect_mock(options).await +} + +/// The kind of an [`announce::Event`] for a prefix. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Start, + Update, + End, +} + +/// Every update per prefix until the cursor goes quiet. Time is paused, so the +/// timeout fires only once every task is idle. +async fn drain(announced: &mut announce::Consumer) -> HashMap> { + let mut updates = HashMap::>::new(); + while let Ok(Some(event)) = tokio::time::timeout(Duration::from_secs(1), announced.next()).await { + let (kind, announce) = match event { + announce::Event::Start(announce) => (Kind::Start, announce), + announce::Event::Update(announce) => (Kind::Update, announce), + announce::Event::End(announce) => (Kind::End, announce), + announce::Event::Live => continue, + }; + updates.entry(announce.prefix.to_string()).or_default().push(kind); + } + updates +} + +/// `n` relays meshed over `edges`, watched from the last one. +struct Mesh { + nodes: Vec, + _pairs: Vec, + announced: announce::Consumer, +} + +impl Mesh { + async fn new(version: &str, n: u64, edges: &[(usize, usize)]) -> Self { + let version: Version = version.parse().unwrap(); + let nodes: Vec<_> = (1..=n).map(produce_origin).collect(); + let mut pairs = Vec::new(); + for &(a, b) in edges { + pairs.push(peer(version, &nodes[a], &nodes[b]).await); + } + let announced = nodes.last().unwrap().consume().announced(); + Self { + nodes, + _pairs: pairs, + announced, + } + } + + /// Publish `count` broadcasts spread over every relay but the watcher, starting + /// at relay `offset`, and require the watcher to see each announced. + async fn publish(&mut self, count: usize, offset: usize) -> Vec { + let relays = self.nodes.len() - 1; + let broadcasts = (0..count) + .map(|i| { + let broadcast = self.nodes[(i + offset) % relays] + .create_broadcast(format!("room/{i}")) + .unwrap(); + broadcast.announce(Default::default()).unwrap(); + broadcast + }) + .collect(); + let updates = drain(&mut self.announced).await; + assert_eq!(updates.len(), count); + for (prefix, kinds) in updates { + assert_eq!(kinds[0], Kind::Start, "{prefix}: {kinds:?}"); + assert_ne!(kinds.last(), Some(&Kind::End), "{prefix}: {kinds:?}"); + } + broadcasts + } +} + +fn full_mesh(n: usize) -> Vec<(usize, usize)> { + (0..n).flat_map(|a| (a + 1..n).map(move |b| (a, b))).collect() +} + +/// A ring with chords: most relays reach a publisher's relay only through others. +fn ring_with_chords(n: usize) -> Vec<(usize, usize)> { + (0..n).flat_map(|a| [(a, (a + 1) % n), (a, (a + 3) % n)]).collect() +} + +/// Every relay neighbors the publisher's, so each hears the withdrawal first-hand +/// and drops every path derived from it at once. Lite04 names the peer only in +/// the chain, later versions in the announce handshake too. +#[tokio::test(start_paused = true)] +async fn full_mesh_withdraw_retracts_once_lite04() { + full_mesh_withdraw_retracts_once("moq-lite-04").await; +} + +#[tokio::test(start_paused = true)] +async fn full_mesh_withdraw_retracts_once_lite06() { + full_mesh_withdraw_retracts_once("moq-lite-06").await; +} + +async fn full_mesh_withdraw_retracts_once(version: &str) { + let mut mesh = Mesh::new(version, 8, &full_mesh(8)).await; + let broadcasts = mesh.publish(100, 0).await; + drop(broadcasts); + let updates = drain(&mut mesh.announced).await; + assert_eq!(updates.len(), 100); + for (prefix, kinds) in updates { + assert_eq!(kinds, [Kind::End], "{prefix}"); + } + // The same relays publish again, clearing their own withdrawals. + let _broadcasts = mesh.publish(100, 0).await; +} + +/// A relay two hops from the publisher's hears only that its neighbor withdrew, not +/// why, so it can still pass through a stale path or two. Every broadcast must still +/// end retracted, and republishing from other relays must reach the watcher again: +/// no withdrawal outlives the peer announcing the path again. +#[tokio::test(start_paused = true)] +async fn partial_mesh_withdraw_then_republish() { + let mut mesh = Mesh::new("moq-lite-06", 12, &ring_with_chords(12)).await; + let broadcasts = mesh.publish(100, 0).await; + drop(broadcasts); + let updates = drain(&mut mesh.announced).await; + assert_eq!(updates.len(), 100); + for (prefix, kinds) in updates { + assert_eq!(kinds.last(), Some(&Kind::End), "{prefix}: {kinds:?}"); + } + let _broadcasts = mesh.publish(100, 5).await; +} diff --git a/rs/moq-net/tests/settled_end_survives_abort.rs b/rs/moq-net/tests/settled_end_survives_abort.rs new file mode 100644 index 0000000000..2ef3826438 --- /dev/null +++ b/rs/moq-net/tests/settled_end_survives_abort.rs @@ -0,0 +1,89 @@ +//! A track whose declared end has settled holds every group it promised: the end was +//! reached and each group below it finished. An abort that lands afterwards, such as +//! the session dying, ends the track cleanly but must not throw those groups away: a +//! consumer that has not read them yet still gets them, then the clean end. + +mod support; + +use moq_net::{Error, Hop, Timestamp}; + +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 +} + +const GROUPS: u64 = 2; + +/// Publish `GROUPS` finished groups, declare the end at `GROUPS`, then abort the track. +/// Returns the sequences a consumer that starts reading only now receives, in arrival +/// or in sequence order, and how the read ended. +async fn round(abort: bool, ordered: bool) -> (Vec, Option) { + let origin = produce_origin(1); + let broadcast = origin.create_broadcast("bcast").unwrap(); + let mut track = broadcast.create_track("video", None).unwrap(); + // From the first group with a replay window: a late reader is owed the whole track, + // not the live edge. + let subscription = moq_net::track::Subscription::default() + .with_start(moq_net::track::Position::group(0)) + .with_max_age(std::time::Duration::from_secs(30)); + let mut consumer = track.subscribe(subscription); + + for _ in 0..GROUPS { + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + } + track.finish_at(GROUPS).unwrap(); + // Every group below the end is finished: the end has settled and the track is + // complete. Nothing has been read yet. + if abort { + track.abort(Error::Session(moq_net::SessionError::Cancel)).unwrap(); + } + + let mut got = Vec::new(); + if ordered { + let mut consumer = consumer.ordered(); + loop { + match consumer.next_group().await { + Ok(Some(group)) => got.push(group.sequence), + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + } + } + } + loop { + match consumer.recv_group().await { + Ok(Some(group)) => got.push(group.sequence), + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + } + } +} + +fn assert_whole((got, err): (Vec, Option), what: &str) { + assert!( + err.is_none() && got == (0..GROUPS).collect::>(), + "{what}: got {got:?} of {GROUPS} groups, err={err:?} (every group had finished before \ + the end, so a late reader gets all of them and then the clean end)" + ); +} + +/// The groups a settled track holds outlive an abort: a late reader gets all of them. +#[tokio::test] +async fn an_abort_after_the_end_settled_keeps_the_groups_for_a_late_reader() { + assert_whole(round(true, false).await, "arrival order, aborted after the end settled"); +} + +/// The same in sequence order: the ordered cursor ends clean too, not with the abort. +#[tokio::test] +async fn an_abort_after_the_end_settled_keeps_the_groups_for_a_late_ordered_reader() { + assert_whole(round(true, true).await, "sequence order, aborted after the end settled"); +} + +/// Control: with no abort the same late reader gets every group, then the clean end. +#[tokio::test] +async fn a_settled_track_delivers_every_group_to_a_late_reader() { + assert_whole(round(false, false).await, "arrival order, no abort"); + assert_whole(round(false, true).await, "sequence order, no abort"); +} diff --git a/rs/moq-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index 3bc1cc63ea..fdd4702af9 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -510,6 +510,22 @@ impl MockSession { } } + /// Deliver the held uni streams newest first, and stop holding. + pub fn release_unis_reversed(&self) { + for stream in self + .side + .held + .lock() + .unwrap() + .take() + .unwrap_or_default() + .into_iter() + .rev() + { + let _ = self.side.peer_uni.try_push(stream); + } + } + /// Lose the held uni streams, as if each were reset before its header arrived, and /// stop holding. pub fn drop_unis(&self) { diff --git a/rs/moq-net/tests/track_tail.rs b/rs/moq-net/tests/track_tail.rs index f9dd8ca201..a2e21403bf 100644 --- a/rs/moq-net/tests/track_tail.rs +++ b/rs/moq-net/tests/track_tail.rs @@ -56,12 +56,12 @@ struct Outcome { elapsed: Duration, } -/// Publish a one-group track, end it while its group stream is held back, then deliver or -/// lose that stream, and return what the subscriber read. -async fn round(version: &str, late: Late) -> Outcome { +/// Publish a group below the declared end (or none for an empty track), hold its stream +/// back until the subscription ends, then deliver or lose it. +async fn round(version: &str, late: Late, final_sequence: u64) -> Outcome { let publisher = produce_origin(1); let broadcast = publisher.create_broadcast("bcast").unwrap(); - let track = broadcast.create_track("video", None).unwrap(); + let mut track = broadcast.create_track("video", None).unwrap(); broadcast.announce(Default::default()).unwrap(); let subscriber = produce_origin(2); @@ -112,19 +112,23 @@ async fn round(version: &str, late: Late) -> Outcome { .unwrap(); pair.server_transport.hold_unis(); - let mut group = track.append_group().unwrap(); - group.write_frame(Timestamp::ZERO, PAYLOAD).unwrap(); - group.finish().unwrap(); - track.finish().unwrap(); + if final_sequence > 0 { + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, PAYLOAD).unwrap(); + group.finish().unwrap(); + } + track.finish_at(final_sequence).unwrap(); drop(track); - // Paused time only advances once every task is idle, so this runs the publisher to its - // end of the subscription and the subscriber through reading it. - tokio::time::sleep(GRACE / 10).await; - assert!( - !reader.is_finished(), - "{version}: the track ended before its group arrived" - ); + if final_sequence > 0 { + // Paused time advances only when every task is idle, after the publisher and + // subscriber have processed the subscription's end. + tokio::time::sleep(GRACE / 10).await; + assert!( + !reader.is_finished(), + "{version}: the track ended before its group arrived" + ); + } let released = tokio::time::Instant::now(); match late { @@ -150,7 +154,7 @@ async fn round(version: &str, late: Late) -> Outcome { async fn a_group_after_the_end_is_delivered() { tokio::time::pause(); for version in VERSIONS { - let outcome = round(version, Late::Delivered).await; + let outcome = round(version, Late::Delivered, 1).await; assert!( outcome.err.is_none() && outcome.frames == [PAYLOAD], "{version}: got {} frame(s), err={:?}", @@ -176,7 +180,7 @@ async fn a_group_after_the_end_is_delivered() { async fn a_lost_group_ends_the_track_after_the_grace() { tokio::time::pause(); for version in VERSIONS { - let outcome = round(version, Late::Lost).await; + let outcome = round(version, Late::Lost, 1).await; assert!( outcome.err.is_none() && outcome.frames.is_empty(), "{version}: got {} frame(s), err={:?}", @@ -190,3 +194,23 @@ async fn a_lost_group_ends_the_track_after_the_grace() { ); } } + +/// Skipped sequences have no stream and must not hold lite-07's counted tail open. +#[tokio::test] +async fn lite07_skipped_groups_end_without_the_grace() { + tokio::time::pause(); + let outcome = round("moq-lite-07-wip", Late::Delivered, 3).await; + assert!(outcome.err.is_none()); + assert_eq!(outcome.frames, [PAYLOAD]); + assert!(outcome.elapsed < GRACE / 10, "ended after {:?}", outcome.elapsed); +} + +/// A zero stream count leaves no tail to wait for. +#[tokio::test] +async fn lite07_zero_streams_end_without_the_grace() { + tokio::time::pause(); + let outcome = round("moq-lite-07-wip", Late::Delivered, 0).await; + assert!(outcome.err.is_none()); + assert!(outcome.frames.is_empty()); + assert!(outcome.elapsed < GRACE / 10, "ended after {:?}", outcome.elapsed); +} diff --git a/rs/moq-pattern/CHANGELOG.md b/rs/moq-pattern/CHANGELOG.md new file mode 100644 index 0000000000..56c0b699cd --- /dev/null +++ b/rs/moq-pattern/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-pattern-v0.1.0...moq-pattern-v0.1.1) - 2026-09-27 + +### Fixed + +- *(pattern)* subtree of a max-depth path is the path itself ([#4284](https://github.com/moq-dev/moq/pull/4284)) diff --git a/rs/moq-pattern/Cargo.toml b/rs/moq-pattern/Cargo.toml index bcaf3935af..6a3d8af3ba 100644 --- a/rs/moq-pattern/Cargo.toml +++ b/rs/moq-pattern/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-pattern/src/pattern.rs b/rs/moq-pattern/src/pattern.rs index a1ce23a99d..49dca7f8c2 100644 --- a/rs/moq-pattern/src/pattern.rs +++ b/rs/moq-pattern/src/pattern.rs @@ -403,9 +403,14 @@ impl Pattern { /// The pattern matching `path` and every path beneath it: `path/**`. /// /// Normalizes and validates `path` like [`literal`](Self::literal). The empty path - /// yields `**`. + /// yields `**`, and a path of [`MAX_SEGMENTS`](Self::MAX_SEGMENTS) yields the literal, + /// since nothing can sit beneath it. pub fn subtree(path: &str) -> Result { - Self::new(literal_segments(path).chain([Segment::Globstar])) + let mut segments: Vec = literal_segments(path).collect(); + if segments.len() < Self::MAX_SEGMENTS { + segments.push(Segment::Globstar); + } + Self::new(segments) } /// The canonical text: segments joined by `/`, wildcards as `*` and `**`. diff --git a/rs/moq-pattern/tests/pattern.json b/rs/moq-pattern/tests/pattern.json index bf479d8c36..ae8b85bdab 100644 --- a/rs/moq-pattern/tests/pattern.json +++ b/rs/moq-pattern/tests/pattern.json @@ -1,5 +1,5 @@ { - "$comment": "Golden vectors shared by rs/moq-net (tests/pattern.rs) and js/net (src/pattern.test.ts). Both test suites read this file, so the two implementations cannot drift apart silently.", + "$comment": "Golden vectors shared by rs/moq-pattern (tests/pattern.rs) and js/pattern (src/index.test.ts). Both test suites read this file, so the two implementations cannot drift apart silently.", "parse": [ { "text": "", @@ -197,6 +197,14 @@ { "path": "a/b/", "pattern": "a/b/**" + }, + { + "path": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f", + "pattern": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f" + }, + { + "path": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f/g", + "error": "too-many-segments" } ], "head": [ diff --git a/rs/moq-pattern/tests/pattern.rs b/rs/moq-pattern/tests/pattern.rs index 1c25242811..2d64bb3911 100644 --- a/rs/moq-pattern/tests/pattern.rs +++ b/rs/moq-pattern/tests/pattern.rs @@ -77,11 +77,14 @@ fn literal_and_subtree() { } for case in vectors["subtree"].as_array().unwrap() { let path = case["path"].as_str().unwrap(); - assert_eq!( - Pattern::subtree(path).unwrap(), - pattern(case["pattern"].as_str().unwrap()), - "subtree {path:?}" - ); + match Pattern::subtree(path) { + Ok(got) => assert_eq!(got, pattern(case["pattern"].as_str().unwrap()), "subtree {path:?}"), + Err(err) => assert_eq!( + error_code(&err), + case["error"].as_str().unwrap_or("ok"), + "subtree {path:?}" + ), + } } } diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index f0ad948636..01140559d4 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.8](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.7...moq-relay-v0.15.8) - 2026-09-27 + +### Added + +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(auth)* root public and mTLS rules at / ([#4318](https://github.com/moq-dev/moq/pull/4318)) + +## [0.15.7](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.6...moq-relay-v0.15.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + +### Fixed + +- *(auth)* keep accepted grants on fixed expiry deadlines ([#4237](https://github.com/moq-dev/moq/pull/4237)) + +### Other + +- origin narrowing joins auth, drop relay peer set, plan hop-list routing ([#4158](https://github.com/moq-dev/moq/pull/4158)) +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) +- *(relay)* run the outage lease test on the real clock ([#4244](https://github.com/moq-dev/moq/pull/4244)) + ## [0.15.6](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.5...moq-relay-v0.15.6) - 2026-09-26 ### Other diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 1dc1a0abcb..8d7e7edece 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.15.6" +version = "0.15.8" edition = "2024" # sysinfo 0.39 (cache governor cgroup limits) needs 1.95, above the 1.91 # workspace floor. moq-relay is lib+bin, so this applies to its library target @@ -91,6 +91,7 @@ sd-notify = { workspace = true } [dev-dependencies] moq-auth = { path = "../moq-auth", features = ["client", "serve"] } moq-shaper = { path = "../moq-shaper" } +qmux = { workspace = true, features = ["tcp"] } rand = { workspace = true } rcgen = "0.14" tempfile = { workspace = true } diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 9481787a99..f3cee4285c 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -35,9 +35,9 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, - /// Patterns an anonymous session may both publish and subscribe to, such as - /// `anon/**`. Repeatable or comma-separated. Sets a static grant with no expiry - /// and no server. + /// Patterns an anonymous session may both publish and subscribe to, rooted at + /// `/`, such as `anon/**`. Repeatable or comma-separated. Sets a static grant + /// with no expiry and no server. #[usage( long = "auth-public", env = "MOQ_AUTH_PUBLIC", @@ -48,7 +48,7 @@ pub struct Config { #[serde_as(as = "OneOrMany<_>")] pub public: Vec, - /// Patterns an anonymous session may subscribe to. Repeatable. + /// Patterns an anonymous session may subscribe to, rooted at `/`. Repeatable. #[usage( long = "auth-public-subscribe", env = "MOQ_AUTH_PUBLIC_SUBSCRIBE", @@ -59,7 +59,7 @@ pub struct Config { #[serde_as(as = "OneOrMany<_>")] pub public_subscribe: Vec, - /// Patterns an anonymous session may publish. Repeatable. + /// Patterns an anonymous session may publish, rooted at `/`. Repeatable. #[usage( long = "auth-public-publish", env = "MOQ_AUTH_PUBLIC_PUBLISH", @@ -69,16 +69,171 @@ pub struct Config { #[serde(skip_serializing_if = "Vec::is_empty")] #[serde_as(as = "OneOrMany<_>")] pub public_publish: Vec, + + /// The 0.14 flags and env vars #3688 removed, kept parsing but hidden. Never + /// read as settings: [`deprecated`](Self::deprecated) names what replaced each. + #[usage(flatten)] + #[serde(skip)] + pub(crate) legacy: Legacy, + + // The 0.14 `[auth]` keys #3688 removed, kept only so they refuse with their + // replacement named rather than being ignored. + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) key: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) key_dir: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) auth_api: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) domains: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) mtls_tier: Option, + #[usage(skip)] + #[serde(skip_serializing)] + pub(crate) tls: Option, +} + +/// The `--auth-*` flags 0.14 had and #3688 removed, with their env vars: a relay +/// deployed through the environment would otherwise ignore them without a word. +#[derive(Clone, Debug, Default, usage::Args)] +#[usage(unknown_flags = "error", args_override_self = false)] +pub(crate) struct Legacy { + #[usage(name = "auth-key", long = "auth-key", env = "MOQ_AUTH_KEY", hide = true)] + key: Option, + #[usage(name = "auth-key-dir", long = "auth-key-dir", env = "MOQ_AUTH_KEY_DIR", hide = true)] + key_dir: Option, + #[usage(name = "auth-api", long = "auth-api", env = "MOQ_AUTH_API", hide = true)] + api: Option, + #[usage( + name = "auth-public-api", + long = "auth-public-api", + env = "MOQ_AUTH_PUBLIC_API", + hide = true + )] + public_api: Option, + #[usage(name = "auth-domain", long = "auth-domain", env = "MOQ_AUTH_DOMAIN", hide = true)] + domain: Vec, + #[usage( + name = "auth-mtls-tier", + long = "auth-mtls-tier", + env = "MOQ_AUTH_MTLS_TIER", + hide = true + )] + mtls_tier: Option, + #[usage( + name = "auth-tls-root", + long = "auth-tls-root", + env = "MOQ_AUTH_TLS_ROOT", + hide = true + )] + tls_root: Vec, + #[usage( + name = "auth-tls-cert", + long = "auth-tls-cert", + env = "MOQ_AUTH_TLS_CERT", + hide = true + )] + tls_cert: Option, + #[usage(name = "auth-tls-key", long = "auth-tls-key", env = "MOQ_AUTH_TLS_KEY", hide = true)] + tls_key: Option, + #[usage( + name = "auth-tls-disable-verify", + long = "auth-tls-disable-verify", + env = "MOQ_AUTH_TLS_DISABLE_VERIFY", + hide = true, + default_missing = "true", + num_args = 0..=1, + require_equals = true + )] + tls_disable_verify: Option, } impl Config { /// The static grant the public patterns name, or `None` when none is set. + /// + /// Its patterns are rooted at `/`, not at a dialed path: each session is granted + /// what they reach from where it dialed, as a token with an empty root would be. pub fn public_grant(&self) -> Option { let publish: Patterns = self.public.iter().chain(&self.public_publish).cloned().collect(); let subscribe: Patterns = self.public.iter().chain(&self.public_subscribe).cloned().collect(); (!publish.is_empty() || !subscribe.is_empty()).then(|| Grant::new(publish, subscribe)) } + /// The 0.14 settings in use, each paired with what replaced it. A relay refuses + /// to start on any: `key` with `public` would otherwise boot with every JWT + /// ignored. + pub fn deprecated(&self) -> moq_tokio::cli::Deprecated { + let mut found = moq_tokio::cli::Deprecated::default(); + let legacy = &self.legacy; + for (set, flag, env, toml, new) in [ + ( + legacy.key.is_some(), + "--auth-key", + "MOQ_AUTH_KEY", + self.key.is_some().then_some("[auth] key"), + "--auth-url to `moq auth serve --key`", + ), + ( + legacy.key_dir.is_some(), + "--auth-key-dir", + "MOQ_AUTH_KEY_DIR", + self.key_dir.is_some().then_some("[auth] key_dir"), + "--auth-url to `moq auth serve --key-dir`", + ), + ( + legacy.api.is_some(), + "--auth-api", + "MOQ_AUTH_API", + self.auth_api.is_some().then_some("[auth] auth_api"), + "--auth-url to a server answering the contract", + ), + ( + legacy.public_api.is_some(), + "--auth-public-api", + "MOQ_AUTH_PUBLIC_API", + None, + "--auth-url to a server answering the contract", + ), + ( + !legacy.domain.is_empty(), + "--auth-domain", + "MOQ_AUTH_DOMAIN", + self.domains.is_some().then_some("[auth] domains"), + "an auth server that reads `server_name`", + ), + ( + legacy.mtls_tier.is_some(), + "--auth-mtls-tier", + "MOQ_AUTH_MTLS_TIER", + self.mtls_tier.is_some().then_some("[auth] mtls_tier"), + "--auth-url to `moq auth serve --tier`", + ), + ( + !legacy.tls_root.is_empty() + || legacy.tls_cert.is_some() + || legacy.tls_key.is_some() + || legacy.tls_disable_verify.is_some(), + "--auth-tls-*", + "MOQ_AUTH_TLS_*", + self.tls.is_some().then_some("[auth.tls]"), + "--connect-tls-*, which an https:// --auth-url presents", + ), + ] { + if set { + found.flag(flag, Some(env), new); + } + if let Some(toml) = toml { + found.toml(toml, new, None); + } + } + found + } + /// Whether no source is named at all. Such a relay admits nothing on its own: /// [`Relay::load`](crate::Relay::load) hands its sessions to the embedder as /// [`Admissions`], and [`validate`](Self::validate) refuses it for a binary. @@ -88,7 +243,27 @@ impl Config { /// Refuse a configuration that admits nobody, or that names both a server and /// a static grant, so the question of who decides has one answer. + /// + /// A public pattern without a wildcard is refused too. 0.14 read `anon` as the + /// prefix `anon/`, and a pattern reads it as exactly the broadcast `anon`, so + /// either silent reading would mislead someone upgrading. pub fn validate(&self) -> anyhow::Result<()> { + let flags = [ + ("--auth-public", &self.public), + ("--auth-public-publish", &self.public_publish), + ("--auth-public-subscribe", &self.public_subscribe), + ]; + for (flag, patterns) in flags { + for pattern in patterns.iter().filter(|pattern| pattern.is_literal()) { + // A literal at the maximum depth is already its own subtree. + let subtree = Pattern::subtree(pattern.as_str())?; + if subtree != *pattern { + anyhow::bail!( + "{flag} `{pattern}` has no wildcard, so it names exactly one broadcast; write `{subtree}` for the subtree" + ); + } + } + } match (&self.url, self.public_grant()) { (Some(_), Some(_)) => anyhow::bail!("--auth-url and --auth-public cannot both be set; the server decides"), (None, None) => anyhow::bail!( @@ -98,6 +273,17 @@ impl Config { } } + /// Refuse a client CA under public rules, which grant a certificate what they + /// grant anyone. `client_ca` is whether any listener verifies client certificates. + pub fn validate_client_ca(&self, client_ca: bool) -> anyhow::Result<()> { + if client_ca && self.url.is_none() && self.public_grant().is_some() { + anyhow::bail!( + "a client CA (--listen-tls-root, --web-https-root) verifies client certificates, which --auth-public ignores; remove it, or grant certificates with --auth-url to `moq auth serve --mtls-*`" + ); + } + Ok(()) + } + /// Build the [`Auth`] this configuration describes. `tls` is the client /// identity an `https://` server is dialed with; `node` names this relay in /// every request. Must be called within a Tokio runtime, which drives the @@ -109,7 +295,11 @@ impl Config { let tls = tls.build()?; Decider::Server(moq_auth::Client::new(url.clone(), Some(tls))?) } - (None, Some(grant)) => Decider::Public(grant), + (None, Some(grant)) => Decider::Public( + moq_auth::Claims::default() + .with_publish(grant.publish) + .with_subscribe(grant.subscribe), + ), (None, None) => unreachable!("validated above"), }; let (auth, admissions) = Auth::embedded(node); @@ -184,6 +374,9 @@ pub struct Token { pub(crate) path: String, /// The root the session is scoped to: the grant's `root` alias, else the dialed path. pub root: PathOwned, + /// Subtrees read from elsewhere on the origin: each path, relative to `root`, + /// resolves at the absolute path it maps to. + pub mounts: Vec<(PathOwned, PathOwned)>, /// The patterns the holder may subscribe to, relative to `root`. pub subscribe: Patterns, /// The patterns the holder may publish to, relative to `root`. @@ -205,6 +398,11 @@ impl Token { Self { path: path.to_string(), root: Path::new(root).to_owned(), + mounts: grant + .mounts + .iter() + .map(|(at, target)| (Path::new(at).to_owned(), Path::new(target).to_owned())) + .collect(), subscribe: grant.subscribe.clone(), publish: grant.publish.clone(), tier: crate::configured_tier(grant.tier.clone()), @@ -219,10 +417,13 @@ impl Token { } /// Whether `other` still covers everything this token scopes: the same root and - /// every grant still held. A narrower re-check closes the session until + /// mounts, and every grant still held. A narrower re-check closes the session until /// pattern scopes can resize it in place. pub(crate) fn covered_by(&self, other: &Self) -> bool { - self.root == other.root && other.subscribe.covers(&self.subscribe) && other.publish.covers(&self.publish) + self.root == other.root + && self.mounts == other.mounts + && other.subscribe.covers(&self.subscribe) + && other.publish.covers(&self.publish) } } @@ -277,7 +478,7 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// 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 changed root or mounts, or a narrower grant, ends it: origin handles cannot yet narrow /// 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 @@ -297,6 +498,9 @@ impl Lease { if fresh.root != self.token.root { return lease::Reason::Narrowed; } + if fresh.mounts != self.token.mounts { + return "mounts changed".into(); + } // Routes the session already announced were recorded as // entering here or from a peer; a flip would misreport them. if fresh.peer != self.token.peer { @@ -348,7 +552,8 @@ pub async fn hold(mut lease: Lease, work: impl std::future::Future admission.grant(lease::Consumer::fixed(grant.clone())), + // Nothing here can verify a token, so one is refused rather than ignored: + // its holder expects it to count, and the public grant is not what it says. + Self::Public(_) if presents_token(&admission.request) => { + tracing::debug!(path = %admission.request.path, "a token was presented to public rules"); + admission.refuse(Error::Refused); + } + Self::Public(rules) => match rules.authorize(&admission.request.path) { + Ok(access) => { + let grant = Grant::new(access.publish, access.subscribe); + admission.grant(lease::Consumer::fixed(grant)); + } + // A path the rules don't reach is a refusal, never an outage a + // client would retry. + Err(err) => { + tracing::debug!(path = %admission.request.path, %err, "public rules refused"); + admission.refuse(Error::Refused); + } + }, Self::Refuse => admission.refuse(Error::Refused), } } @@ -374,6 +596,17 @@ impl Decider { } } +/// Whether `request` carries a token: a SETUP token of any type, or a non-empty +/// `jwt` query parameter, the convention `moq auth serve` reads. +fn presents_token(request: &Request) -> bool { + request.token.is_some() + || request.query.as_deref().is_some_and(|query| { + query + .split('&') + .any(|pair| pair.strip_prefix("jwt=").is_some_and(|jwt| !jwt.is_empty())) + }) +} + /// Admits sessions by queueing every request for one admission decider. #[derive(Clone)] pub struct Auth { @@ -497,6 +730,10 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request }; let mut out = auth.request(transport, path); out.query = request.query().map(str::to_owned); + out.token = request.token().map(|token| moq_auth::Token { + kind: token.kind, + value: token.value.clone(), + }); out.remote = request.remote_addr(); out.local = request.local_addr(); out.server_name = request @@ -563,16 +800,111 @@ mod tests { assert!(grant.publish.is_empty()); } + /// The public rules are rooted at `/`, like a token with an empty root. Bare `**` + /// reads the same either way, which is how rooting them at the dialed path went + /// unnoticed: `anon/**` at `/rooms/123` granted `rooms/123/anon/**`. + #[tokio::test] + async fn public_rules_are_rooted_at_slash() { + let auth = Config { + public_publish: patterns(&["anon/**"]).into_iter().collect(), + public_subscribe: patterns(&["anon/**", "*/chat"]).into_iter().collect(), + ..Default::default() + } + .init("relay-1", &moq_tokio::tls::Connect::default()) + .unwrap(); + + for (path, root, publish, subscribe) in [ + ("/", "", &["anon/**"][..], &["anon/**", "*/chat"][..]), + ("/anon", "anon", &["**"], &["**", "chat"]), + ("/anon/room", "anon/room", &["**"], &["**"]), + ("/rooms", "rooms", &[], &["chat"]), + ] { + let lease = auth.admit(auth.request(moq_auth::Transport::Quic, path)).await.unwrap(); + assert_eq!(lease.token().root, Path::new(root).to_owned(), "{path}"); + assert_eq!(lease.token().publish, patterns(publish), "{path}"); + assert_eq!(lease.token().subscribe, patterns(subscribe), "{path}"); + } + + // A path the rules don't reach is refused, never an outage the client retries. + for path in ["/rooms/123", "/other/room", "/anonymous/room"] { + let Err(err) = auth.admit(auth.request(moq_auth::Transport::Quic, path)).await else { + panic!("{path} was admitted"); + }; + assert!(matches!(err, Error::Refused), "{path}: {err}"); + assert_eq!(http::StatusCode::from(&err), http::StatusCode::UNAUTHORIZED); + } + } + + /// Public rules verify nothing, so a token is refused rather than ignored, in + /// whichever form it arrives. + #[tokio::test] + async fn public_rules_refuse_a_token() { + let auth = config(None, &["**"]) + .init("relay-1", &moq_tokio::tls::Connect::default()) + .unwrap(); + let mut query = auth.request(moq_auth::Transport::WebSocket, "/"); + query.query = Some("a=1&jwt=eyJ".into()); + let mut setup = auth.request(moq_auth::Transport::Quic, "/"); + setup.token = Some(moq_auth::Token { + kind: moq_auth::Token::CAT, + value: b"anything".to_vec(), + }); + let mut http = auth.request(moq_auth::Transport::Http, "/"); + http.query = Some("jwt=eyJ".into()); + for request in [query, setup, http] { + let Err(err) = auth.admit(request).await else { + panic!("a token was admitted on the public grant"); + }; + assert!(matches!(err, Error::Refused), "{err}"); + } + + // An empty `jwt=` is no token. + let mut empty = auth.request(moq_auth::Transport::WebSocket, "/"); + empty.query = Some("jwt=&b=2".into()); + assert!(auth.admit(empty).await.is_ok()); + } + + /// A certificate is a fact the public rules ignore: it gets exactly what anyone does. #[tokio::test] async fn a_public_config_admits_anonymous_and_certificate_alike() { let auth = config(None, &["anon/**"]) .init("relay-1", &moq_tokio::tls::Connect::default()) .unwrap(); - let request = auth.request(moq_auth::Transport::Quic, "/anon/room"); - let lease = auth.admit(request).await.unwrap(); - assert_eq!(lease.token().root, Path::new("anon/room").to_owned()); - assert_eq!(lease.token().subscribe, patterns(&["anon/**"])); - assert_eq!(lease.token().tier, Tier::default()); + let anonymous = auth.request(moq_auth::Transport::Quic, "/anon/room"); + let mut certificate = anonymous.clone(); + certificate.tls = Some(moq_auth::Peer { + name: "edge0".into(), + fingerprint: "ab".repeat(32), + expires: None, + issuer: "CN=cluster".into(), + }); + for request in [anonymous, certificate] { + let lease = auth.admit(request).await.unwrap(); + assert_eq!(lease.token().root, Path::new("anon/room").to_owned()); + assert_eq!(lease.token().subscribe, patterns(&["**"])); + assert_eq!(lease.token().tier, Tier::default()); + } + } + + /// 0.14 read `anon` as a prefix and a pattern reads it as one broadcast, so a + /// wildcard-free public pattern refuses to start rather than pick silently. + #[test] + fn a_public_pattern_without_a_wildcard_refuses_to_start() { + for (public, hint) in [("anon", "anon/**"), ("anon/room", "anon/room/**"), ("", "**")] { + let err = config(None, &[public]).validate().unwrap_err().to_string(); + assert!(err.contains(&format!("`{hint}`")), "{public}: {err}"); + } + let split = Config { + public_subscribe: patterns(&["anon/**", "live"]).into_iter().collect(), + ..Default::default() + }; + let err = split.validate().unwrap_err().to_string(); + assert!(err.starts_with("--auth-public-subscribe `live`"), "{err}"); + assert!(config(None, &["anon/*", "*/chat", "**"]).validate().is_ok()); + + // Nothing sits beneath a literal at the maximum depth, so it is its own subtree. + let deepest = vec!["a"; Pattern::MAX_SEGMENTS].join("/"); + assert!(config(None, &[&deepest]).validate().is_ok()); } #[test] @@ -667,27 +999,19 @@ mod tests { assert_eq!(reason, lease::Reason::Expired); } - #[tokio::test] - async fn a_grant_within_clock_skew_stays_live() { - tokio::time::pause(); + #[tokio::test(start_paused = true)] + async fn an_expired_grant_ends_at_once() { use std::time::Duration; let mut grant = Grant::new(patterns(&["**"]), patterns(&["**"])); grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); - grant.validate().expect("accepted inside the skew window"); + assert!(matches!(grant.validate(), Err(moq_auth::Error::GrantExpired))); + + let start = tokio::time::Instant::now(); let mut lease = Lease::new("/room", lease::Consumer::fixed(grant)); - assert!( - tokio::time::timeout(Duration::from_secs(1), lease.ended()) - .await - .is_err(), - "still live inside the skew window" - ); - assert_eq!( - tokio::time::timeout(Duration::from_secs(4), lease.ended()) - .await - .expect("expired once the skew window ended"), - lease::Reason::Expired - ); + assert_eq!(lease.ended().await, lease::Reason::Expired); + // A millisecond for Tokio's timer resolution. + assert!(start.elapsed() <= Duration::from_millis(1), "no grace after expiry"); } #[test] @@ -711,10 +1035,20 @@ mod tests { let wide = Token::new("/room", &Grant::new(patterns(&["**"]), patterns(&["**"]))); let narrow = Token::new("/room", &Grant::new(patterns(&["alice/**"]), patterns(&["**"]))); let moved = Token::new("/other", &Grant::new(patterns(&["**"]), patterns(&["**"]))); + let mut mounted = Grant::new(patterns(&["**"]), patterns(&["**"])); + mounted.mounts.insert(".svc".into(), ".svc/room".into()); + let mounted = Token::new("/room", &mounted); + assert_eq!( + mounted.mounts, + [(Path::new(".svc").to_owned(), Path::new(".svc/room").to_owned())] + ); assert!(wide.covered_by(&wide)); assert!(narrow.covered_by(&wide)); assert!(!wide.covered_by(&narrow)); assert!(!wide.covered_by(&moved)); + // A mount moves what a path resolves to, so either way it is a new scope. + assert!(!wide.covered_by(&mounted)); + assert!(!mounted.covered_by(&wide)); } /// Routes a session announced were recorded as a peer's or not; a re-check that diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index c7b9539b3f..e35414d1cb 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1276,19 +1276,36 @@ impl Cluster { /// Passed by reference to [`moq_net::Server::with_publisher`] (or the /// equivalent per-request setter), which derives the read handle. pub fn subscriber(&self, token: &auth::Token) -> Option { - self.origin.scope(&token.root, &token.subscribe).ok() + self.mounted(token)?.scope(&token.root, &token.subscribe).ok() } /// Returns an [`origin::Producer`] scoped to this session's publish permissions, /// marked [`origin::Producer::peer`] when the grant names a cluster peer. + /// Nothing is published beneath the grant's mounts. pub fn publisher(&self, token: &auth::Token) -> Option { - let publisher = self.origin.scope(&token.root, &token.publish).ok()?; + let publisher = self.mounted(token)?.scope(&token.root, &token.publish).ok()?; Some(match token.peer { true => publisher.peer(), false => publisher, }) } + /// The origin with the grant's mounts applied, before it is scoped to the + /// session. A mount the origin refuses (overlapping another) admits nothing. + fn mounted(&self, token: &auth::Token) -> Option { + let mut origin = self.origin.clone(); + for (at, target) in &token.mounts { + origin = match origin.mount(token.root.join(at), target) { + Ok(origin) => origin, + Err(err) => { + tracing::warn!(root = %token.root, %at, %target, %err, "grant mount refused"); + return None; + } + }; + } + Some(origin) + } + /// Resolve whether gossip is on and which URL this relay advertises, from /// `cluster.node` and the (string-typed) `cluster.mesh` toggle. /// @@ -2330,6 +2347,57 @@ mod tests { Cluster::new(Options::new(config)) } + /// A grant's mount reaches the target for subscribe and refuses publish, both + /// named from the session's root. + #[tokio::test] + async fn session_handles_apply_grant_mounts() { + let cluster = new_cluster(Config::default()).expect("cluster"); + let everything = || moq_net::Patterns::from(moq_net::Pattern::all()); + let mut grant = moq_auth::Grant::new(everything(), everything()); + grant.mounts.insert(".svc".into(), ".svc/pid".into()); + let token = auth::Token::new("/pid", &grant); + + let _worker = cluster + .origin + .publish(".svc/pid/foo", origin::Route::default()) + .expect("publish at the fleet path"); + let subscriber = cluster.subscriber(&token).expect("subscribe grant").consume(); + let broadcast = subscriber + .request_broadcast(".svc/foo") + .await + .expect("resolves through the mount"); + assert_eq!(broadcast.info().path.as_str(), ".svc/foo"); + + let publisher = cluster.publisher(&token).expect("publish grant"); + assert!(publisher.create_broadcast(".svc/foo").is_err()); + publisher.create_broadcast("cam").expect("publish outside the mount"); + + // A mount the origin refuses admits nothing rather than half a grant. + grant.mounts.insert(".svc/x".into(), ".other".into()); + let token = auth::Token::new("/pid", &grant); + assert!(cluster.subscriber(&token).is_none()); + assert!(cluster.publisher(&token).is_none()); + } + + /// A grant whose mounts chain, or whose mount point is a wildcard, admits + /// nothing. Mounts apply in key order, so the chain is written with the + /// mount point on the target sorting last. + #[tokio::test] + async fn session_handles_refuse_invalid_grant_mounts() { + let cluster = new_cluster(Config::default()).expect("cluster"); + let everything = || moq_net::Patterns::from(moq_net::Pattern::all()); + let invalid: [&[(&str, &str)]; 2] = [&[(".a", "pid/.z"), (".z", "secret")], &[("*", ".svc/pid")]]; + for mounts in invalid { + let mut grant = moq_auth::Grant::new(everything(), everything()); + for (at, target) in mounts { + grant.mounts.insert((*at).into(), (*target).into()); + } + let token = auth::Token::new("/pid", &grant); + assert!(cluster.subscriber(&token).is_none(), "{mounts:?}"); + assert!(cluster.publisher(&token).is_none(), "{mounts:?}"); + } + } + /// The publish task holds only a `Weak` to its producer, so it stops when the /// last `moq_stats::Producer` clone drops. Attaching one must therefore hand /// its lifetime to the cluster: an embedder clones handles off a `Relay` and diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index 3fca841c59..658a3e0035 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -310,6 +310,7 @@ impl Config { deprecated.extend(self.listen.deprecated()); deprecated.extend(self.connect.deprecated()); deprecated.extend(self.cluster.deprecated()); + deprecated.extend(self.auth.deprecated()); if let Some(server) = &self.server { deprecated.toml("[server]", "[listen]", None); deprecated.extend(server.deprecated()); @@ -423,6 +424,66 @@ max_streams = 64 assert!(err.contains("both directions"), "{err}"); } + /// A 0.14 `[auth]` config with `key` and `public` would otherwise boot with + /// every JWT ignored; each removed key refuses with its replacement named. + #[test] + fn released_auth_keys_refuse_to_boot() { + let toml = r#" +[auth] +key = "root.jwk" +key_dir = "keys/" +auth_api = "https://api.example.com/auth" +domains = ["example.com"] +mtls_tier = "internal" +public = "anon/**" + +[auth.tls] +root = ["ca.pem"] +"#; + let mut config: Config = toml::from_str(toml).expect("released config must still parse"); + let err = config.resolve().expect_err("must refuse").to_string(); + for old in [ + "[auth] key -> --auth-url to `moq auth serve --key`", + "[auth] key_dir -> --auth-url to `moq auth serve --key-dir`", + "[auth] auth_api -> ", + "[auth] domains -> ", + "[auth] mtls_tier -> --auth-url to `moq auth serve --tier`", + "[auth.tls] -> --connect-tls-*", + ] { + assert!(err.contains(old), "{old}: {err}"); + } + } + + /// The environment is the half a removed flag silently misses: a relay deployed + /// through it never typed the flag. + #[test] + fn released_auth_env_refuses_to_boot() { + let vars = [ + ("MOQ_AUTH_KEY", "--auth-key / MOQ_AUTH_KEY"), + ("MOQ_AUTH_KEY_DIR", "--auth-key-dir / MOQ_AUTH_KEY_DIR"), + ("MOQ_AUTH_API", "--auth-api / MOQ_AUTH_API"), + ("MOQ_AUTH_PUBLIC_API", "--auth-public-api / MOQ_AUTH_PUBLIC_API"), + ("MOQ_AUTH_DOMAIN", "--auth-domain / MOQ_AUTH_DOMAIN"), + ("MOQ_AUTH_MTLS_TIER", "--auth-mtls-tier / MOQ_AUTH_MTLS_TIER"), + ("MOQ_AUTH_TLS_ROOT", "--auth-tls-* / MOQ_AUTH_TLS_*"), + ]; + let _env = EnvGuard::clear(&vars.map(|(var, _)| var)); + for (var, spelling) in vars { + unsafe { std::env::set_var(var, "x") }; + let err = Config::parse_and_merge(["moq-relay", "--auth-public", "**"]) + .expect_err("must refuse") + .to_string(); + unsafe { std::env::remove_var(var) }; + assert!(err.contains(spelling), "{var}: {err}"); + } + + // 0.14 took the bare flag, so it has to parse to be refused by name. + let err = Config::parse_and_merge(["moq-relay", "--auth-public", "**", "--auth-tls-disable-verify"]) + .expect_err("must refuse") + .to_string(); + assert!(err.contains("--auth-tls-* / MOQ_AUTH_TLS_*"), "{err}"); + } + /// A released flag and a released table are refused together, in one message. /// /// The flag lands on a hidden field the merge's TOML round-trip drops, so a diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 082ab27978..cdf037dac9 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -123,6 +123,9 @@ impl Relay { .clone() .or_else(|| config.cluster.node.clone()) .unwrap_or_default(); + config + .auth + .validate_client_ca(!(config.listen.tls.root.is_empty() && config.web.https.root.is_empty()))?; // No `[auth]` source means the embedder decides: it takes the admissions // before `run`, which refuses to start if nobody did. let (auth, admissions) = match config.auth.is_empty() { diff --git a/rs/moq-relay/src/session.rs b/rs/moq-relay/src/session.rs index f1c1950aee..a4ff6f5ec4 100644 --- a/rs/moq-relay/src/session.rs +++ b/rs/moq-relay/src/session.rs @@ -136,7 +136,7 @@ impl Registry { /// /// `id` and every scalar are exact. `path` is a [`Pattern`] against the dialed /// path. `remote` is an IP or CIDR with the port dropped and IPv4-mapped IPv6 -/// folded. `query` is not a field: it may carry the credential. +/// folded. `query` and `token` are not fields: they carry the credential. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Filter { /// Session id. @@ -396,7 +396,7 @@ impl IntoResponse for Error { } /// One live session as the list route returns it: the request the server saw, -/// minus `query`, plus when it was admitted. +/// minus its credentials (`query` and `token`), plus when it was admitted. #[serde_as] #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -447,7 +447,7 @@ impl View { /// `GET /sessions` body. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct List { - /// Matching sessions, `query` omitted. + /// Matching sessions, credentials omitted. pub sessions: Vec, } @@ -539,14 +539,21 @@ mod tests { } #[test] - fn list_omits_query() { + fn list_omits_credentials() { let registry = Registry::new(); - let _reg = registry.register(request("abc", "/room", "127.0.0.1:1")); + let mut session = request("abc", "/room", "127.0.0.1:1"); + session.query = Some("jwt=secret".into()); + session.token = Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: b"secret".to_vec(), + }); + let _reg = registry.register(session); let list = registry.list(&Filter::default()); assert_eq!(list.len(), 1); assert_eq!(list[0].id, "abc"); let json = serde_json::to_value(&list[0]).unwrap(); assert!(json.get("query").is_none()); + assert!(json.get("token").is_none()); } #[tokio::test] diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index c3b66c57e4..3f33562ed9 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -348,12 +348,57 @@ async fn admits_and_reports_the_session() { assert!(connect.remote.is_some_and(|addr| addr.ip().is_loopback())); assert!(connect.local.is_some_and(|addr| addr.port() == port)); assert!(connect.tls.is_none()); + assert!(connect.token.is_none(), "moq-lite carries no SETUP token"); drop(pub_session); drop(sub_session); relay.abort(); } +/// A moq-transport client's SETUP token reaches the auth server byte for byte. +#[tokio::test] +async fn forwards_the_setup_token() { + use web_transport_trait::{RecvStream as _, SendStream as _, Session as _}; + + let script = Script::new(grant(Duration::from_secs(3600))); + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + + // No client presents a SETUP token yet, so write a draft-16 CLIENT_SETUP by hand. One + // parameter, AUTHORIZATION TOKEN (3), holding USE_VALUE (3), Token Type 0, then a + // value that is not text, so any lossy step on the way shows. + let value = [0x00, 0xff, 0x80, b'x']; + let token = [&[0x03, 0x00][..], &value].concat(); + let params = [&[0x01, 0x03, token.len() as u8][..], &token].concat(); + let setup = [&[0x20][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); + + let session = qmux::tcp::Config::new(qmux::Version::QMux01) + .protocols(["moqt-16"]) + .connect(("127.0.0.1", port)) + .await + .expect("connect"); + let (mut send, mut recv) = session.open_bi().await.expect("open the control stream"); + send.write_all(&setup).await.expect("send CLIENT_SETUP"); + + // The relay answers SERVER_SETUP only once the auth server has admitted the session. + let mut reply = [0u8; 1]; + tokio::time::timeout(TIMEOUT, recv.read(&mut reply)) + .await + .expect("SERVER_SETUP timeout") + .expect("SERVER_SETUP"); + + let seen = script.seen.lock().unwrap().clone(); + let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); + assert_eq!( + connect.token, + Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: value.to_vec(), + }) + ); + + relay.abort(); +} + /// A refusal at connect, a 5xx, and a garbage reply all refuse the session. #[tokio::test] async fn refusals_and_outages_refuse_at_connect() { @@ -813,7 +858,7 @@ async fn a_certificate_admits_only_what_the_server_grants() { let serve = |policy: moq_auth::serve::Policy| async move { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url: url::Url = format!("http://{}/", listener.local_addr().unwrap()).parse().unwrap(); - let server = moq_auth::serve::Server::new(policy); + let server = moq_auth::serve::Server::new(policy).unwrap(); tokio::spawn(async move { server.serve(listener).await }); url }; @@ -845,7 +890,7 @@ async fn a_certificate_admits_only_what_the_server_grants() { ))) .await; let (addr, relay) = spawn_quic_relay(build_auth(narrow), Some(root.clone())).await; - let url: url::Url = format!("moql://127.0.0.1:{}/room", addr.port()).parse().unwrap(); + let url: url::Url = format!("moql://127.0.0.1:{}/mine", addr.port()).parse().unwrap(); let origin = moq_tokio::origin::spawn(); let session = tokio::time::timeout( TIMEOUT, @@ -866,6 +911,9 @@ async fn a_certificate_admits_only_what_the_server_grants() { ); // A subscribe-only client has nothing granted, so it is refused at the handshake. assert_refused_with(mtls_client(), &url).await; + // The rules are rooted at `/`, so a certificate dialed outside `mine` gets nothing. + let room: url::Url = format!("moql://127.0.0.1:{}/room", addr.port()).parse().unwrap(); + assert_refused_with(mtls_client(), &room).await; drop(session); relay.abort(); } diff --git a/rs/moq-relay/tests/public_rules.rs b/rs/moq-relay/tests/public_rules.rs new file mode 100644 index 0000000000..cb7be07523 --- /dev/null +++ b/rs/moq-relay/tests/public_rules.rs @@ -0,0 +1,275 @@ +//! Public rules are rooted at `/`, like a token with an empty root, through a real +//! relay: on the relay's own `--auth-public` and on `moq auth serve --public-*` +//! behind `--auth-url`. +//! +//! Every other test grants bare `**`, which reads the same whether rooted at `/` or +//! at the dialed path. These use `anon/**` and `event/**`, which do not: rooted at +//! the dialed path, `anon/**` at `/anon` meant `anon/anon/**`, and at `/rooms/123` +//! it let an anonymous client into `rooms/123/anon/**`. + +use std::time::Duration; + +use moq_relay::{auth, cluster, web}; +use moq_tokio::moq_net; + +const TIMEOUT: Duration = Duration::from_secs(10); + +fn client() -> moq_tokio::Client { + let mut config = moq_tokio::connect::Config::default(); + config.once = Some(true); + config.websocket.delay = Duration::ZERO; + config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); + config.init(Default::default()).expect("client init") +} + +/// The relay's web stack with WebSocket and the HTTP routes, admitting through `auth`. +async fn spawn_relay(auth: auth::Config) -> (u16, tokio::task::JoinHandle<()>) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let auth = auth + .init("test", &moq_tokio::tls::Connect::default()) + .expect("auth init"); + let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); + + // Only the certificate handle is used; stream listeners bind lazily. + let mut server_config = moq_tokio::listen::Config::default(); + server_config.bind = Some("[::]:0".parse().unwrap()); + server_config.tls.generate = vec!["localhost".into()]; + let certificates = server_config + .init(Default::default()) + .expect("server init") + .certificates(); + + let mut web_config = web::Config::default(); + web_config.ws = true; + web_config.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); + let web = web::Web::new(auth, cluster, certificates, web_config) + .bind() + .expect("bind web listener"); + let port = web.addrs().http.expect("HTTP listener is configured").port(); + let handle = tokio::spawn(async move { + let _ = web.run().await; + }); + (port, handle) +} + +fn url(port: u16, path: &str) -> url::Url { + format!("ws://127.0.0.1:{port}{path}").parse().expect("parse url") +} + +/// Publish `name` at `url` with one open group holding `hello`. Keep the returned +/// handles alive for as long as the broadcast should stay announced. +async fn publish(url: url::Url, name: &str) -> Box { + let origin = moq_tokio::origin::spawn(); + let broadcast = origin.create_broadcast(name).expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("video", None).expect("create track"); + let mut group = track.append_group().expect("append group"); + group + .write_frame(moq_net::Timestamp::ZERO, b"hello".as_ref()) + .expect("write frame"); + let session = tokio::time::timeout( + TIMEOUT, + client() + .with_publisher(origin.consume()) + .with_reconnect(false) + .connect(url) + .established(), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); + Box::new((session, broadcast, track, group)) +} + +/// Subscribe at `url` and return the first broadcast announced, after reading one +/// frame of its `video` track. +async fn first_broadcast(url: url::Url) -> String { + let origin = moq_tokio::origin::spawn(); + let consumer = origin.consume(); + let mut announcements = consumer.announced(); + let _session = tokio::time::timeout( + TIMEOUT, + client() + .with_subscriber(origin) + .with_reconnect(false) + .connect(url) + .established(), + ) + .await + .expect("subscriber connect timeout") + .expect("subscriber connect failed"); + + let update = tokio::time::timeout(TIMEOUT, async { + loop { + match announcements.next().await.expect("origin closed") { + moq_net::announce::Event::Start(update) => break update, + // The local origin is caught up before the session brings anything. + moq_net::announce::Event::Live => continue, + other => panic!("expected announce, got {other:?}"), + } + } + }) + .await + .expect("announcement timeout"); + let name = update.prefix.to_string(); + + let broadcast = consumer.request_broadcast(&name).await.expect("broadcast resolves"); + let mut track = broadcast + .track("video") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"); + let mut group = tokio::time::timeout(TIMEOUT, track.recv_group()) + .await + .expect("group timeout") + .expect("group failed") + .expect("track closed"); + let frame = tokio::time::timeout(TIMEOUT, group.read_frame()) + .await + .expect("frame timeout") + .expect("frame failed") + .expect("group closed"); + assert_eq!(&frame.payload[..], b"hello"); + name +} + +/// A session the relay refuses never carries media: the transport may finish its +/// handshake before the verdict, so an established one has to close right away. +async fn assert_refused(url: url::Url) { + let origin = moq_tokio::origin::spawn(); + let result = tokio::time::timeout( + TIMEOUT, + client() + .with_subscriber(origin) + .with_reconnect(false) + .connect(url.clone()) + .established(), + ) + .await + .expect("connect timeout"); + if let Ok(session) = result { + let closed = tokio::time::timeout(Duration::from_secs(3), session.closed()) + .await + .unwrap_or_else(|_| panic!("the relay admitted {url}")); + assert!(closed.is_err(), "a refused session at {url} closed cleanly"); + } +} + +fn anon() -> auth::Config { + let mut config = auth::Config::default(); + config.public = vec!["anon/**".parse().unwrap()]; + config +} + +#[tokio::test] +async fn anon_rules_admit_under_anon_and_refuse_elsewhere() { + let (port, relay) = spawn_relay(anon()).await; + + let _publisher = publish(url(port, "/anon"), "test.hang").await; + assert_eq!(first_broadcast(url(port, "/")).await, "anon/test.hang"); + assert_eq!(first_broadcast(url(port, "/anon")).await, "test.hang"); + + // Rooted at the dialed path, this was `rooms/123/anon/**`. + assert_refused(url(port, "/rooms/123")).await; + assert_refused(url(port, "/other")).await; + + relay.abort(); +} + +#[tokio::test] +async fn a_leading_wildcard_does_not_reach_into_rooms() { + let mut config = auth::Config::default(); + config.public_subscribe = vec!["*".parse().unwrap()]; + let (port, relay) = spawn_relay(config).await; + + // Rooted at the dialed path, `*` at `/rooms/123` was every broadcast in the room. + assert_refused(url(port, "/rooms/123")).await; + + relay.abort(); +} + +#[tokio::test] +async fn http_routes_follow_the_anon_rules() { + let (port, relay) = spawn_relay(anon()).await; + let _publisher = publish(url(port, "/anon/bbb"), "cam").await; + // The HTTP routes report only what has reached the relay. + assert_eq!(first_broadcast(url(port, "/anon")).await, "bbb/cam"); + + let http = reqwest::Client::new(); + let get = |path: &str| http.get(format!("http://127.0.0.1:{port}{path}")).send(); + + let announced = get("/announced/anon").await.expect("announced request"); + assert_eq!(announced.status(), 200); + assert_eq!(announced.text().await.expect("announced body").trim(), "bbb/cam"); + + let mut fetch = get("/fetch/anon/bbb/cam/video").await.expect("fetch request"); + assert_eq!(fetch.status(), 200); + let first = tokio::time::timeout(TIMEOUT, fetch.chunk()) + .await + .expect("fetch timeout") + .expect("fetch body") + .expect("fetch body ended early"); + assert_eq!(&first[..], b"hello"); + + for path in ["/announced/other", "/fetch/other/cam/video"] { + assert_eq!(get(path).await.expect("request").status(), 401, "{path}"); + } + + relay.abort(); +} + +/// The reported upgrade: `moq auth serve --public-subscribe 'event/**'` behind +/// `--auth-url`, with a viewer at `/event` watching a camera published under a +/// `root=event` token. Rooted at the dialed path, the viewer saw `event/event/**`. +#[tokio::test] +async fn auth_server_public_rules_are_rooted_at_slash() { + let dir = tempfile::tempdir().expect("tempdir"); + let key = moq_auth::Key::generate(moq_auth::Algorithm::HS256, None).expect("generate key"); + let key_path = dir.path().join("key.jwk"); + key.to_file(&key_path).expect("write key"); + + let mut policy = moq_auth::serve::Policy::default(); + policy.keys = Some(moq_auth::serve::Keys::File(key_path)); + policy.public = moq_auth::Permissions::new(Default::default(), ["event/**".parse().unwrap()].into_iter().collect()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server_url: url::Url = format!("http://{}/", listener.local_addr().unwrap()).parse().unwrap(); + let server = moq_auth::serve::Server::new(policy).unwrap(); + tokio::spawn(async move { server.serve(listener).await }); + + let mut config = auth::Config::default(); + config.url = Some(server_url); + let (port, relay) = spawn_relay(config).await; + + let claims = moq_auth::Claims::default() + .with_root("event") + .with_publish(["**".parse().unwrap()]); + let jwt = key.sign(&claims).expect("sign"); + let mut camera = url(port, "/event"); + camera.query_pairs_mut().append_pair("jwt", &jwt); + let _publisher = publish(camera, "cam1.hang").await; + + assert_eq!(first_broadcast(url(port, "/event")).await, "cam1.hang"); + assert_refused(url(port, "/rooms/123")).await; + + relay.abort(); +} + +/// Public rules grant a certificate what they grant anyone, so a client CA on a +/// public-only relay is refused rather than left to verify certificates for nothing. +#[tokio::test] +async fn a_client_ca_needs_an_auth_server() { + for web in [false, true] { + let mut config = moq_relay::Config::default(); + config.auth = anon(); + match web { + false => config.listen.tls.root = vec!["ca.pem".into()], + true => config.web.https.root = vec!["ca.pem".into()], + } + let error = match moq_relay::Relay::load(config).await { + Ok(_) => panic!("a client CA was accepted under --auth-public"), + Err(error) => error.to_string(), + }; + assert!(error.contains("--auth-public ignores"), "{error}"); + } +} diff --git a/rs/moq-relay/tests/runtime_uring.rs b/rs/moq-relay/tests/runtime_uring.rs index 2e3ae87389..b6d5cf48bd 100644 --- a/rs/moq-relay/tests/runtime_uring.rs +++ b/rs/moq-relay/tests/runtime_uring.rs @@ -489,7 +489,7 @@ async fn spawn_auth_server(policy: moq_auth::serve::Policy) -> url::Url { let url = format!("http://{}/", listener.local_addr().expect("auth addr")) .parse() .expect("auth url"); - let server = moq_auth::serve::Server::new(policy); + let server = moq_auth::serve::Server::new(policy).unwrap(); tokio::spawn(async move { server.serve(listener).await }); url } diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 24fc70b61b..1f468466f3 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -579,7 +579,7 @@ async fn two_publish_only_clients_coexist() { /// Run the relay's accept loop over the given server config, the same path /// `main.rs` uses. Authenticates through the shared [`Auth`], here with fully -/// public access (`--auth-public ""`) so no-JWT clients get the root. +/// public access (`--auth-public "**"`) so no-JWT clients get the root. /// /// Returns the QUIC and TCP sockets the server bound, when it has them, so a /// caller that asked for an ephemeral port can dial it. @@ -628,7 +628,7 @@ async fn spawn_internal_relay() -> (u16, tokio::task::JoinHandle<()>) { let mut config = moq_tokio::listen::Config::default(); config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); - // Public Simple([""]) lets any no-JWT stream client through at the root. + // Public `**` lets any no-JWT stream client through at the root. let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; @@ -730,7 +730,7 @@ async fn spawn_internal_unix_relay() -> (std::path::PathBuf, tokio::task::JoinHa let mut config = moq_tokio::listen::Config::default(); config.unix.bind = Some(path.clone()); - // Public Simple([""]) lets any no-JWT stream client through at the root. + // Public `**` lets any no-JWT stream client through at the root. let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; diff --git a/rs/moq-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md index b28e108eae..8f4460b081 100644 --- a/rs/moq-room/CHANGELOG.md +++ b/rs/moq-room/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.8](https://github.com/moq-dev/moq/compare/moq-room-v0.2.7...moq-room-v0.2.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, moq-json, moq-auth + +## [0.2.7](https://github.com/moq-dev/moq/compare/moq-room-v0.2.6...moq-room-v0.2.7) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net, moq-auth, moq-json + ## [0.2.6](https://github.com/moq-dev/moq/compare/moq-room-v0.2.5...moq-room-v0.2.6) - 2026-09-26 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index 2b0df1e037..e7e0fc2be6 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.6" +version = "0.2.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index e96565dce7..c5a89cf070 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.8](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.7...moq-rtc-v0.3.8) - 2026-09-27 + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.6...moq-rtc-v0.3.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.3.6](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.5...moq-rtc-v0.3.6) - 2026-09-26 ### Other diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index ea23c0717f..6317324ae8 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.6" +version = "0.3.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index ab9f59cf87..eae8a5aa82 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -192,8 +192,8 @@ fn valid_reference(source: &moq_mux::Source, broadcast: Option<&moq_net::path::R source.resolve_reference(broadcast).is_some() } -/// Find the first catalog rendition for the given codec and build a -/// [`codec::Track`] subscribed to it, honoring an optional cross-broadcast +/// Find a catalog rendition for the given codec (the best one, for video) and +/// build a [`codec::Track`] subscribed to it, honoring an optional cross-broadcast /// reference (the rendition's catalog `broadcast` field). Returns `None` if no /// rendition matches. async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) -> Result> { @@ -218,12 +218,7 @@ async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) - Codec::Av1 => VideoCodecKind::AV1, _ => unreachable!(), }; - let Some((name, config)) = catalog - .video - .renditions - .iter() - .find(|(_, c)| c.codec.kind() == target && valid_reference(source, c.broadcast.as_ref())) - else { + let Some((name, config)) = pick_video(source, catalog, target) else { return Ok(None); }; let track = source.subscribe_track(config.broadcast.as_ref(), name).await?; @@ -233,6 +228,19 @@ async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) - } } +/// The best [ranked](hang::catalog::Video::ranked) video rendition in `target`'s codec +/// that `source` can reach. +fn pick_video<'a>( + source: &moq_mux::Source, + catalog: &'a Catalog, + target: VideoCodecKind, +) -> Option<(&'a String, &'a hang::catalog::VideoConfig)> { + catalog + .video + .ranked() + .find(|(_, c)| c.codec.kind() == target && valid_reference(source, c.broadcast.as_ref())) +} + /// Per-rendition pump task. Reads frames, converts the timestamp into the /// codec's clock domain, and forwards as a [`WriteRequest`]. async fn pump(mid: Mid, pt: Pt, clock_rate: Frequency, mut track: codec::Track, tx: mpsc::Sender) { @@ -345,6 +353,39 @@ mod tests { assert_eq!(egress.catalog_codecs(), vec![Codec::Vp8]); } + #[test] + fn picks_the_best_video_rendition_whatever_its_name() { + let origin = produce_origin(); + let source = moq_mux::Source::new(origin.consume(), "a/pub"); + let mut catalog = Catalog::default(); + + let rendition = |height: u32| { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + config.coded_width = Some(height * 16 / 9); + config.coded_height = Some(height); + config + }; + // Name order would pick the lowest rendition. + catalog.video.renditions.insert("a".to_string(), rendition(360)); + catalog.video.renditions.insert("b".to_string(), rendition(1080)); + catalog.video.renditions.insert("c".to_string(), rendition(720)); + catalog + .video + .renditions + .insert("d".to_string(), VideoConfig::new(VideoCodec::VP8)); + + let (name, _) = pick_video(&source, &catalog, VideoCodecKind::H264).unwrap(); + assert_eq!(name, "b"); + let (name, _) = pick_video(&source, &catalog, VideoCodecKind::VP8).unwrap(); + assert_eq!(name, "d"); + assert!(pick_video(&source, &catalog, VideoCodecKind::AV1).is_none()); + } + #[test] fn egress_clock_ignores_cross_track_dequeue_jitter() { let mut clock = EgressClock::default(); diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index b5fb3d8b25..f81ac82a2c 100644 --- a/rs/moq-rtmp/CHANGELOG.md +++ b/rs/moq-rtmp/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.8](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.7...moq-rtmp-v0.3.8) - 2026-09-27 + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.6...moq-rtmp-v0.3.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.3.6](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.5...moq-rtmp-v0.3.6) - 2026-09-26 ### Other diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 1335fb73aa..1057d73d9d 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/moq-dev/moq" # src/rml/LICENSE and applies to that module regardless of which option you pick. license = "MIT OR Apache-2.0" -version = "0.3.6" +version = "0.3.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index 847671df16..c5218a4f7c 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -41,9 +41,10 @@ use crate::rml::time::RtmpTimestamp; use futures::StreamExt; use futures::future::BoxFuture; use futures::stream::FuturesUnordered; -use hang::catalog::{AudioCodec, VideoCodec}; +use hang::catalog::{AudioCodec, VideoCodecKind, VideoConfig}; use moq_mux::catalog::{CatalogFormat, Stream as CatalogStream}; use moq_mux::container::flv::{Export as FlvExport, Import as FlvImport}; +use moq_mux::select; use moq_net::origin; use socket2::{SockRef, TcpKeepalive}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; @@ -776,10 +777,13 @@ impl Play { } } }; - if let Err(reason) = check_play_capabilities(&catalog, &self.capabilities) { - tracing::debug!(peer = %self.peer, %path, %reason, "rejecting RTMP play: unsupported client capabilities"); - return self.reject(&reason).await; - } + let select = match play_selection(&catalog, &self.capabilities) { + Ok(select) => select, + Err(reason) => { + tracing::debug!(peer = %self.peer, %path, %reason, "rejecting RTMP play: unsupported client capabilities"); + return self.reject(&reason).await; + } + }; // The export re-resolves the broadcast (and any sibling broadcast a rendition's // catalog `broadcast` field references) through the origin. @@ -787,7 +791,8 @@ impl Play { .await .map_err(|e| anyhow::anyhow!("init FLV export: {e}"))? .with_max_age(self.latency) - .with_multitrack(self.capabilities.multitrack); + .with_multitrack(self.capabilities.multitrack) + .with_select(select); // Resolve the catalog and codec headers before Play.Start, too. Otherwise a // broadcast that never produces a playable FLV header looks successful to the @@ -868,24 +873,34 @@ impl Play { } } -fn check_play_capabilities( +/// Narrow a play to the video the client can decode, or refuse it. +/// +/// A multitrack client receives every rendition, so it must play them all. A +/// single-track client receives the best video rendition it can play. +fn play_selection( catalog: &moq_mux::catalog::hang::Catalog, capabilities: &ClientCapabilities, -) -> std::result::Result<(), String> { - let limit = if capabilities.multitrack { usize::MAX } else { 1 }; - - for config in catalog.video.renditions.values().take(limit) { - let Some(fourcc) = video_fourcc(&config.codec, capabilities.multitrack) else { - continue; - }; - if !capabilities.supports_video(&fourcc) { - return Err(format!( +) -> std::result::Result { + let playable = |config: &VideoConfig| plays_video(capabilities, config.codec.kind()); + let refused = if capabilities.multitrack { + catalog.video.renditions.values().find(|config| !playable(config)) + } else if catalog.video.renditions.values().any(playable) { + None + } else { + catalog.video.ranked().next().map(|(_, config)| config) + }; + if let Some(config) = refused { + return Err(match video_fourcc(config.codec.kind(), capabilities.multitrack) { + Some(fourcc) => format!( "client did not advertise required RTMP FourCC {}", fourcc_label(&fourcc) - )); - } + ), + None => format!("RTMP can't carry video codec {}", config.codec), + }); } + // Audio is still the first rendition by name for a single-track client. + let limit = if capabilities.multitrack { usize::MAX } else { 1 }; for config in catalog.audio.renditions.values().take(limit) { let Some(fourcc) = audio_fourcc(&config.codec, capabilities.multitrack) else { continue; @@ -898,16 +913,40 @@ fn check_play_capabilities( } } - Ok(()) + let mut video = select::Video::default(); + let mut any = false; + for kind in [ + VideoCodecKind::H264, + VideoCodecKind::H265, + VideoCodecKind::AV1, + VideoCodecKind::VP9, + ] { + if plays_video(capabilities, kind) { + video = video.codec(kind); + any = true; + } + } + // An empty codec list would select every codec, so a client that plays none gets no video. + let select = select::Broadcast::default().audio(select::Audio::default()); + Ok(if any { select.video(video) } else { select }) } -fn video_fourcc(codec: &VideoCodec, multitrack: bool) -> Option<[u8; 4]> { - match codec { - VideoCodec::H264(_) if multitrack => Some(*b"avc1"), - VideoCodec::H265(_) => Some(*b"hvc1"), - VideoCodec::AV1(_) => Some(*b"av01"), - VideoCodec::VP9(_) => Some(*b"vp09"), - VideoCodec::H264(_) | VideoCodec::VP8 | VideoCodec::Unknown(_) => None, +/// Whether a client with `capabilities` can play `kind` over FLV. +fn plays_video(capabilities: &ClientCapabilities, kind: VideoCodecKind) -> bool { + match video_fourcc(kind, capabilities.multitrack) { + Some(fourcc) => capabilities.supports_video(&fourcc), + // Every client plays H.264 by its legacy CodecID; nothing else goes without a FourCC. + None => kind == VideoCodecKind::H264, + } +} + +/// The enhanced-RTMP FourCC a client must advertise to play `kind`, if any. +fn video_fourcc(kind: VideoCodecKind, multitrack: bool) -> Option<[u8; 4]> { + match kind { + VideoCodecKind::H264 if multitrack => Some(*b"avc1"), + VideoCodecKind::H265 => Some(*b"hvc1"), + VideoCodecKind::AV1 => Some(*b"av01"), + VideoCodecKind::VP9 => Some(*b"vp09"), _ => None, } } @@ -1769,6 +1808,68 @@ mod tests { server_task.await.unwrap(); } + /// A single-track client gets the best rendition it can decode, whatever the + /// names; a multitrack client must decode every rendition. + #[test] + fn play_selection_picks_the_best_playable_rendition() { + fn rendition(codec: impl Into, height: u32) -> VideoConfig { + let mut config = VideoConfig::new(codec); + config.coded_width = Some(height * 16 / 9); + config.coded_height = Some(height); + config + } + let h264 = hang::catalog::H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }; + + let mut catalog = moq_mux::catalog::hang::Catalog::default(); + // Name order would pick the lowest rendition. + catalog + .video + .renditions + .insert("a".to_string(), rendition(h264.clone(), 360)); + catalog.video.renditions.insert( + "b".to_string(), + rendition( + hang::catalog::H265 { + in_band: false, + profile_space: 0, + profile_idc: 1, + profile_compatibility_flags: [0x60, 0, 0, 0], + tier_flag: false, + level_idc: 120, + constraint_flags: [0x90, 0, 0, 0, 0, 0], + }, + 1080, + ), + ); + catalog.video.renditions.insert("c".to_string(), rendition(h264, 720)); + + let best = |capabilities: &ClientCapabilities| { + let select = play_selection(&catalog, capabilities).unwrap(); + let mut catalog = catalog.clone(); + select.retain(&mut catalog); + catalog.video.ranked().next().map(|(name, _)| name.clone()) + }; + + let legacy = ClientCapabilities::default(); + assert_eq!(best(&legacy).as_deref(), Some("c")); + + let hevc = FourCcSupport { + any: false, + fourccs: vec![*b"hvc1"], + }; + let enhanced = ClientCapabilities::new(0, hevc.clone(), FourCcSupport::default()); + assert_eq!(best(&enhanced).as_deref(), Some("b")); + + // Multitrack carries every rendition, so one the client can't decode refuses the play. + let multitrack = ClientCapabilities::new(CAPS_EX_MULTITRACK, hevc, FourCcSupport::default()); + assert!(play_selection(&catalog, &multitrack).is_err()); + } + #[test] fn play_capability_check_uses_per_kind_decode_support() { let mut catalog = moq_mux::catalog::hang::Catalog::default(); @@ -1786,7 +1887,7 @@ mod tests { fourccs: vec![*b"vp09"], }; assert!( - check_play_capabilities( + play_selection( &catalog, &ClientCapabilities::new(0, video_support, FourCcSupport::default()) ) @@ -1798,7 +1899,7 @@ mod tests { fourccs: vec![*b"vp09"], }; assert!( - check_play_capabilities( + play_selection( &catalog, &ClientCapabilities::new(0, FourCcSupport::default(), audio_support) ) @@ -1810,7 +1911,7 @@ mod tests { fourccs: Vec::new(), }; assert!( - check_play_capabilities( + play_selection( &catalog, &ClientCapabilities::new(0, wildcard, FourCcSupport::default()) ) diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index eeb7c8ce93..9d9defe076 100644 --- a/rs/moq-srt/CHANGELOG.md +++ b/rs/moq-srt/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.8](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.7...moq-srt-v0.3.8) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net, moq-mux + +## [0.3.7](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.6...moq-srt-v0.3.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.3.6](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.5...moq-srt-v0.3.6) - 2026-09-26 ### Other diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index a6ed2225bc..44dd4bba92 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.6" +version = "0.3.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index 35cb95084d..4143caec72 100644 --- a/rs/moq-stats/CHANGELOG.md +++ b/rs/moq-stats/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.8](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.7...moq-stats-v0.2.8) - 2026-09-27 + +### Fixed + +- *(stats)* keep an idle path in the frame while its counters live ([#4299](https://github.com/moq-dev/moq/pull/4299)) + +## [0.2.7](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.6...moq-stats-v0.2.7) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.2.6](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.5...moq-stats-v0.2.6) - 2026-09-26 ### Other diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 3df8fb6278..d2ba4ac428 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.6" +version = "0.2.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index baecfe039a..b1e35ea3ee 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -901,16 +901,16 @@ fn requested_track_shape(name: &str) -> Option { }) } -/// Change-detection state for one `(path, tier, side)` slot, owned by the -/// publish task. The task is single-threaded so this needs no atomics. +/// Emission state for one `(path, tier, side)` slot, owned by the publish +/// task. The task is single-threaded so this needs no atomics. #[derive(Default)] struct SlotState { - /// Last [`Traffic`] we emitted for this slot, used to detect changes that - /// warrant re-emission. - prev_emitted: Option, + /// Whether any counter on this side has moved since the registry created + /// the entry. + moved: bool, } -/// Change-detection state for one `(path, tier)`: a [`SlotState`] per side. +/// Emission state for one `(path, tier)`: a [`SlotState`] per side. #[derive(Default)] struct SideSlots { publisher: SlotState, @@ -919,7 +919,7 @@ struct SideSlots { seen: u64, } -/// Change-detection state for one session-track root, mirroring [`SlotState`]. +/// Change-detection state for one session-track root. #[derive(Default)] struct SessionSlotState { prev_emitted: Option, @@ -927,40 +927,28 @@ struct SessionSlotState { seen: u64, } -/// Per-drain work for a single `(side, tier)` slot: update the slot's -/// `prev_emitted` and hand `snap` to `emit` iff the slot is live or changed -/// this drain. +/// Per-drain work for a single `(side, tier)` slot: hand `snap` to `emit` once +/// the side has moved, on every drain until the registry drops the entry. fn process_slot(snap: Traffic, slot_state: &mut SlotState, emit: impl FnOnce(Traffic)) { - // A slot is live while any started counter still exceeds its `*_ended` - // counterpart: a guard is held, so a subscription could begin at any - // moment. Live slots are emitted every drain so a downstream "currently - // active" view always sees the full set. Once every pair is equal no - // traffic can flow and the entry is on its way out (the registry pruned - // it as soon as the last guard released its handle). - let live = !snap.is_idle(); - - // Include the entry whenever it's live OR its snapshot changed this - // drain. Change-driven inclusion catches bumps since the previous drain - // (incl. sub-interval flickers) and emits the final close snapshot on the - // drain a slot transitions to fully closed. + // A side can go idle while its entry lives on: the last viewer leaves but + // the publisher still holds the path, so the egress counters are kept and + // resume where they stopped. Omitting it would make its return look like a + // fresh entry to a reader diffing frames, and the old total would count + // twice. So once moved, a side stays in every frame until `flush` drops its + // state with the entry, and a path missing from a frame really restarted. // - // `None` (slot never emitted) is treated as the default Traffic so a - // first-drain all-zeros snap on an unused tier-side slot doesn't count - // as a "change". Without this, every entry would surface in all four - // tracks with zeros on the drain after creation even if only one slot - // is actually in use. - let prev_snap = slot_state.prev_emitted.unwrap_or_default(); - let changed = snap != prev_snap; - if changed { - slot_state.prev_emitted = Some(snap); - } - if live || changed { + // A side that never moved stays out, so an entry with traffic on one side + // does not surface on the other track as zeros. A live side has a started + // counter above zero, so it has always moved. + slot_state.moved |= snap != Traffic::default(); + if slot_state.moved { emit(snap); } } -/// Per-drain work for one session-track root: same live-or-changed rule as -/// [`process_slot`]. +/// Per-drain work for one session-track root: emit it while a session is +/// connected, and on the drain its counters change. A root's counters are +/// dropped with its last session, so it never idles in the registry. fn process_session_slot(snap: Presence, slot_state: &mut SessionSlotState, emit: impl FnOnce(Presence)) { let live = snap.active() > 0; let prev_snap = slot_state.prev_emitted.unwrap_or_default(); @@ -1282,8 +1270,7 @@ mod tests { // A subscription that opens AND closes within a single drain window // must still surface as a complete broadcasts start/end cycle. The // cumulative counters retain broadcasts_started=1/broadcasts_ended=1, and the - // change-driven inclusion surfaces the entry even though it's net-idle - // by drain time. + // entry surfaces because it moved, even though it's net-idle by drain time. let (producer, origin) = test_producer(Some("sjc")); { // Subscribe, read one 123-byte frame, then drop everything within the @@ -1305,6 +1292,66 @@ mod tests { assert_eq!(snap.frames, 1); } + #[tokio::test(start_paused = true)] + async fn a_path_stays_in_the_frame_while_its_counters_live() { + // The publisher holds `foo/bar` throughout while viewers come and go, so + // the registry keeps its egress counters between them. A reader diffing + // frames must see the path the whole time: if it vanished, its return + // (carrying the first viewer's bytes) would look like a fresh entry. + let (producer, origin) = test_producer(Some("sjc")); + let registry = producer.registry(); + let data = produce_origin(); + let source = data + .clone() + .with_stats(registry.tier(Tier::default()).session("publisher")) + .publish("foo/bar", origin::Route::default()) + .expect("publish"); + let mut video = source.create_track("video", None).expect("create_track"); + let egress = data + .consume() + .with_stats(registry.tier(Tier::default()).session("viewer")); + + async fn view(egress: &origin::Consumer, video: &mut track::Producer, size: usize) { + let broadcast = egress.request_broadcast("foo/bar").await.expect("resolve"); + let mut sub = broadcast + .track("video") + .expect("track") + .subscribe(None) + .await + .expect("subscribe"); + let mut group = video.append_group().expect("group"); + group.write_frame(Timestamp::ZERO, vec![0u8; size]).expect("write"); + group.finish().expect("finish"); + let mut group = sub.recv_group().await.expect("recv").expect("group"); + while group.read_frame().await.expect("read").is_some() {} + } + + view(&egress, &mut video, 1000).await; + let (_, stats) = announced(&origin).await; + for _ in 0..3 { + drive_tick().await; + let frame = read_last_frame(&stats, "publisher.json").await; + let snap = frame.get("foo/bar").expect("kept while its counters live"); + assert_eq!(snap.bytes, 1000); + assert!(snap.is_idle(), "the viewer left"); + } + + view(&egress, &mut video, 500).await; + drive_tick().await; + let frame = read_last_frame(&stats, "publisher.json").await; + assert_eq!(frame["foo/bar"].bytes, 1500, "resumed, not restarted"); + + // Once nothing holds the path the registry drops it, and so does the frame. + drop((video, source)); + drive_tick().await; + drive_tick().await; + let frame = read_last_frame(&stats, "publisher.json").await; + assert!( + !frame.contains_key("foo/bar"), + "dropped with its counters, got {frame:?}" + ); + } + #[tokio::test(start_paused = true)] async fn session_track_surfaces_by_root() { let (producer, origin) = test_producer(Some("sjc")); diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index 9feb940d26..d89727bf47 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.19](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.18...moq-tokio-v0.19.19) - 2026-09-27 + +### Added + +- *(net)* the SETUP AUTHORIZATION TOKEN option reaches the verifier ([#4278](https://github.com/moq-dev/moq/pull/4278)) + +### Fixed + +- *(cli)* close the relay connection on SIGINT and SIGTERM ([#4287](https://github.com/moq-dev/moq/pull/4287)) + +### Other + +- fix three load-only test failures at the cause ([#4286](https://github.com/moq-dev/moq/pull/4286)) + +## [0.19.18](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.17...moq-tokio-v0.19.18) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + +### Fixed + +- *(net)* end a track with its session's error when the session dies ([#4120](https://github.com/moq-dev/moq/pull/4120)) + ## [0.19.17](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.16...moq-tokio-v0.19.17) - 2026-09-26 ### Other diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 14e0a53689..0e3a459dc2 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.17" +version = "0.19.19" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index e9133a8e89..562d471e48 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -237,6 +237,23 @@ impl Client { Connection::new(self.clone(), addrs.into()) } + /// Close every QUIC connection this client dialed, once each peer has been sent the + /// close. + /// + /// Clones share one endpoint, so this closes theirs too. Dropping the connections + /// only queues the close, which nothing sends once the runtime stops: a process + /// that exits without this leaves each peer waiting out its idle timeout. + /// + /// Only the noq endpoint is closed. WebSocket, TCP, and UDS sessions end when their + /// [`Connection`] is dropped (the kernel closes the socket on exit), and an iroh + /// endpoint passed to `with_iroh` is closed by its owner. + pub async fn close(self) { + #[cfg(feature = "noq")] + if let Some(noq) = self.noq { + noq.close().await; + } + } + /// Connect to the configured [`connect.url`](crate::connect::Config::url) URL, publishing /// `origin` to it. /// diff --git a/rs/moq-tokio/src/noq.rs b/rs/moq-tokio/src/noq.rs index e75d8bd708..99b4bc48aa 100644 --- a/rs/moq-tokio/src/noq.rs +++ b/rs/moq-tokio/src/noq.rs @@ -325,6 +325,14 @@ impl NoqClient { }) } + /// Close every connection, then wait until each has sent its close to the peer. + pub async fn close(self) { + self.quic.close(noq::VarInt::from_u32(0), b"client shutdown"); + // Not `wait_idle`, which also sits out each connection's 3 PTO closing + // period: that only repeats the close to a peer that already has it. + self.quic.wait_all_draining().await; + } + pub async fn connect( &self, tls: &rustls::ClientConfig, diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 4c7367dee4..702432abaa 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1470,6 +1470,14 @@ impl Request { request_ref!(self, r => r.peer_hop()) } + /// The credential a moq-transport client presented in its SETUP's `AUTHORIZATION + /// TOKEN` option, unverified. moq-lite sessions return `None`. + /// + /// Like [`query`](Self::query), it can hold a credential. Avoid logging it. + pub fn token(&self) -> Option<&moq_net::setup::Token> { + request_ref!(self, r => r.token()) + } + /// The client certificate chain the peer presented, if any, validated /// against a configured [`crate::tls::Listen::root`] during the handshake. /// diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index ab6f911c78..9ee7e63ddf 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -381,10 +381,14 @@ async fn connect_tls_override( .get(http::header::HOST) .cloned() .ok_or(Error::MissingHostname)?; + // tokio-tungstenite takes the TLS name from the URL host, so a bare IPv6 override + // needs the URL brackets that `set_host` refuses to add. let mut tls_url = url.clone(); - tls_url - .set_host(Some(tls_host_name)) - .map_err(|_| Error::connect(qmux::Error::InvalidServerName))?; + match tls_host_name.parse::() { + Ok(ip) => tls_url.set_ip_host(ip), + Err(_) => tls_url.set_host(Some(tls_host_name)).map_err(|_| ()), + } + .map_err(|_| Error::connect(qmux::Error::InvalidServerName))?; let mut request = tls_url .as_str() .into_client_request() @@ -702,18 +706,40 @@ mod tests { #[tokio::test] async fn tls_host_name_override_dials_url_address() { - check_tls_authority(false).await; + check_tls_authority("127.0.0.1", false, Some("relay.example")).await; } #[tokio::test] async fn fixed_addresses_keep_tls_name_and_request_host() { tokio::time::pause(); - check_tls_authority(true).await; + check_tls_authority("relay.example", true, None).await; + } + + #[tokio::test] + async fn ipv6_literal() { + check_tls_authority("[::1]", false, None).await; + } + + #[tokio::test] + async fn ipv6_literal_fixed_addresses() { + tokio::time::pause(); + check_tls_authority("[::1]", true, None).await; + } + + #[tokio::test] + async fn ipv6_tls_host_name_override() { + check_tls_authority("[::1]", false, Some("::1")).await; } - async fn check_tls_authority(fixed: bool) { - let rcgen::CertifiedKey { cert, signing_key } = - rcgen::generate_simple_self_signed(["relay.example".to_string()]).unwrap(); + /// Dial `wss://{url_host}` on loopback, optionally pinned to fixed addresses, and + /// check the TLS name and HTTP `Host` the server sees. + async fn check_tls_authority(url_host: &str, fixed: bool, tls_name: Option<&str>) { + let ipv6 = url_host.starts_with('['); + let name = tls_name.unwrap_or(url_host.trim_start_matches('[').trim_end_matches(']')); + // Clients send SNI only for DNS names, never for IP addresses. + let expected_sni = name.parse::().is_err().then_some(name); + + let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed([name.to_string()]).unwrap(); let cert = CertificateDer::from(cert); let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der())); let provider = crate::crypto::provider(); @@ -732,7 +758,8 @@ mod tests { .with_root_certificates(roots) .with_no_client_auth(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let loopback = if ipv6 { "[::1]:0" } else { "127.0.0.1:0" }; + let listener = tokio::net::TcpListener::bind(loopback).await.unwrap(); let addr = listener.local_addr().unwrap(); let accepted = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -771,23 +798,21 @@ mod tests { }); let config = Config::default(); - let host = if fixed { "relay.example" } else { "127.0.0.1" }; - let url = Url::parse(&format!("wss://{host}:{}/anon", addr.port())).unwrap(); + let url = Url::parse(&format!("wss://{url_host}:{}/anon", addr.port())).unwrap(); // A TCP-only race would select this silent TLS peer and strand the dial. - let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let silent = tokio::net::TcpListener::bind(loopback).await.unwrap(); let target = if fixed { crate::connect::Addr::pinned(url, [silent.local_addr().unwrap(), addr]).unwrap() } else { url.into() }; - let tls_name = (!fixed).then_some("relay.example"); - let expected_host = format!("{host}:{}", addr.port()); + let expected_host = format!("{url_host}:{}", addr.port()); let session = connect(&config, &client_tls, tls_name, target, moq_net::ALPNS) .await .unwrap(); drop(session); let (server_name, host) = accepted.await.unwrap(); - assert_eq!(server_name.as_deref(), Some("relay.example")); + assert_eq!(server_name.as_deref(), expected_sni); assert_eq!(host.as_deref(), Some(expected_host.as_str())); } diff --git a/rs/moq-tokio/tests/backend.rs b/rs/moq-tokio/tests/backend.rs index 4c56cdd73d..3c3fb3e926 100644 --- a/rs/moq-tokio/tests/backend.rs +++ b/rs/moq-tokio/tests/backend.rs @@ -338,40 +338,30 @@ async fn reload_test() { server_config.tls.cert = vec![cert.clone()]; server_config.tls.key = vec![key.clone()]; + let server = server_config + .init(moq_tokio::quic::Config::default()) + .expect("failed to init server"); + let server = server.listen().await.expect("failed to listen"); + + // Every process the user runs shares one inotify instance limit, so a loaded host can refuse + // the listener its watcher. Judge by the listener's own watcher: a separate probe would race + // the rest of the host for the same limit. #[cfg(feature = "watch")] - if moq_tokio::watch::Files::new(std::slice::from_ref(&cert)).is_err() { + if tracing_test::internal::logs_with_scope_contain("moq_tokio", "hot reload disabled") { eprintln!("skipping reload_test: host cannot start an inotify watcher"); return; } - let server = server_config - .init(moq_tokio::quic::Config::default()) - .expect("failed to init server"); - let server = server.listen().await.expect("failed to listen"); let certificates = server.certificates(); let before = certificates.fingerprints(); assert_eq!(before.len(), 1); - // The reload task is spawned while the listener is built and registers its - // watcher the first time the runtime polls it, which may be after this point. - // Rotating before then would replace the files with nothing watching them. - tokio::time::sleep(Duration::from_millis(200)).await; - - // Rotate in place, the way cert-manager or a secret mount would. + // Rotate in place, the way cert-manager or a secret mount would. The listener registered its + // watch before returning, so the rotation cannot land before it. let (new_cert, new_key) = write_self_signed(dir.path(), "rotated", "localhost"); std::fs::rename(&new_cert, &cert).expect("rotate cert"); std::fs::rename(&new_key, &key).expect("rotate key"); - tokio::time::sleep(Duration::from_secs(2)).await; - assert!( - tracing_test::internal::logs_with_scope_contain("moq_tokio", "reloading server certificates"), - "no reload log" - ); - assert!( - !tracing_test::internal::logs_with_scope_contain("moq_tokio", "hot reload disabled"), - "watcher failed" - ); - let reloaded = tokio::time::timeout(TIMEOUT, async { loop { let now = certificates.fingerprints(); @@ -384,6 +374,10 @@ async fn reload_test() { .await .expect("certificate reload timed out"); + assert!( + tracing_test::internal::logs_with_scope_contain("moq_tokio", "reloading server certificates"), + "no reload log" + ); assert_eq!(reloaded.len(), 1); drop(server); } @@ -670,6 +664,64 @@ async fn iroh_connect() { // ── Noq backend ───────────────────────────────────────────────────── +/// A client that closes before its runtime stops tells the server at once, instead of +/// leaving it to the idle timeout, which is what a process exiting on a signal does. +#[cfg(feature = "noq")] +#[tracing_test::traced_test] +#[tokio::test] +async fn noq_client_close_reaches_server() { + let quic = moq_tokio::quic::Config::default(); + assert!( + quic.idle_timeout > TIMEOUT, + "an idle timeout inside TIMEOUT would hide a lost close" + ); + + let mut server_config = moq_tokio::listen::Config::default(); + server_config.bind = Some("127.0.0.1:0".parse().unwrap()); + server_config.tls.generate = vec!["localhost".into()]; + let server = server_config.init(quic.clone()).expect("failed to init server"); + let mut server = server.listen().await.expect("failed to listen"); + let url: url::Url = format!("moqt://localhost:{}", server.local_addr().unwrap().port()) + .parse() + .unwrap(); + + // The client gets a runtime of its own, gone as soon as the client returns: nothing + // drives its endpoint afterwards, exactly as when a process exits. + let client = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("client runtime"); + runtime.block_on(async move { + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.bind = Some("127.0.0.1:0".parse().unwrap()); + let client = config + .init(quic) + .expect("failed to init client") + .with_subscriber(moq_tokio::origin::spawn()); + let (client, connection) = connect_once(client, url).await.expect("client connect failed"); + drop(connection); + client.close().await; + }); + }); + + let request = tokio::time::timeout(TIMEOUT, server.accept()) + .await + .expect("accept timed out") + .expect("no incoming connection"); + let session = request.ok().await.expect("server handshake failed"); + tokio::task::spawn_blocking(move || client.join()) + .await + .unwrap() + .expect("client thread panicked"); + + let err = tokio::time::timeout(TIMEOUT, session.closed()) + .await + .expect("the server never heard the close"); + assert!(!err.to_string().contains("timed out"), "{err}"); +} + #[cfg(feature = "noq")] #[tracing_test::traced_test] #[tokio::test] diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index ee90a8c0b6..a8fe965b83 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.6...moq-transcode-v0.1.7) - 2026-09-27 + +### Fixed + +- *(egress)* single-rendition egress serves the best rendition ([#4293](https://github.com/moq-dev/moq/pull/4293)) + +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.5...moq-transcode-v0.1.6) - 2026-09-26 + +### Added + +- end a broadcast with close() in every language ([#4031](https://github.com/moq-dev/moq/pull/4031)) + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.4...moq-transcode-v0.1.5) - 2026-09-26 ### Other diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index f58c3ec02d..2c34597c75 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 5568b15626..8f741798cf 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -158,32 +158,20 @@ impl Decoders { } } -/// Pick the rendition to transcode from: the highest-resolution rendition local +/// Pick the rendition to transcode from: the best [ranked](Video::ranked) rendition local /// to the source broadcast that this host can decode. /// /// [`Error::NoSource`] means wait for a later snapshot. Any other error means -/// nothing on offer can be decoded here, and is why the tallest one refused. +/// nothing on offer can be decoded here, and is why the best one refused. pub(crate) async fn choose_source(video: &Video, decoders: &mut Decoders) -> Result<(String, VideoConfig), Error> { - let mut candidates: Vec<_> = video - .renditions - .iter() + // Best first. A rendition without dimensions ranks after every one with them: + // it can't be chosen yet, but it can still keep the transcoder waiting. + let candidates = video + .ranked() // A rendition that itself lives in another broadcast can't be subscribed // through this one; composing relative references is a follow-up. .filter(|(_, config)| config.broadcast.is_none()) - .filter_map(|(name, config)| Some((name, config, codec(config)?))) - .collect(); - // Largest first, ties going to the last name as they always have. A rendition - // without dimensions sorts after every one with them: it can't be chosen yet, - // but it can still keep the transcoder waiting. - candidates.reverse(); - candidates.sort_by_key(|(_, config, _)| { - std::cmp::Reverse(( - dimensions(config).is_some(), - config.coded_height, - config.coded_width, - config.bitrate, - )) - }); + .filter_map(|(name, config)| Some((name, config, codec(config)?))); let mut refused = None; for (name, config, codec) in candidates { @@ -749,14 +737,14 @@ mod tests { let (name, _) = choose_source(&video, &mut decoders).await.unwrap(); assert_eq!(name, "avc"); - // Hardware that decodes H.265 keeps the usual pick of the tallest it can. + // Hardware that decodes H.265 keeps the usual pick of the largest it can. let (name, _) = choose_source(&video, &mut Decoders::assume(&[Codec::H264, Codec::H265])) .await .unwrap(); assert_eq!(name, "hevc"); } - /// Nothing on offer decodes here: a refusal carrying why the tallest + /// Nothing on offer decodes here: a refusal carrying why the largest /// rendition's decoder refused, rather than waiting on a complete catalog. #[tokio::test] async fn refuses_when_no_rendition_decodes() { diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 99e1f1fa84..2fbbd371cb 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -1452,7 +1452,7 @@ mod tests { .expect("run kept waiting for a source it can never decode"); match result { - // Why the tallest rendition's decoder refused. + // Why the largest rendition's decoder refused. Err(Error::Video(moq_video::Error::UnknownDecoder { name, codec, .. })) => { assert_eq!(name, "missing"); assert_eq!(codec, moq_video::decode::Codec::H265); diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index 80c3577805..7e87c776df 100644 --- a/rs/moq-uring/CHANGELOG.md +++ b/rs/moq-uring/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.9](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.8...moq-uring-v0.0.9) - 2026-09-27 + +### Other + +- updated the following local packages: moq-net + +## [0.0.8](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.7...moq-uring-v0.0.8) - 2026-09-26 + +### Other + +- updated the following local packages: kio, moq-net + ## [0.0.7](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.6...moq-uring-v0.0.7) - 2026-09-26 ### Other diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 10fce20851..af9e9fd8e0 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.7" +version = "0.0.9" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 3914d39499..70f717d661 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.7](https://github.com/moq-dev/moq/compare/moq-video-v0.1.6...moq-video-v0.1.7) - 2026-09-27 + +### Fixed + +- *(video)* keep the CUDA context alive until the NVDEC decoder is destroyed ([#4290](https://github.com/moq-dev/moq/pull/4290)) + +## [0.1.6](https://github.com/moq-dev/moq/compare/moq-video-v0.1.5...moq-video-v0.1.6) - 2026-09-26 + +### Added + +- *(moq-mux)* catalog delay measures cross-rendition encoder lateness ([#4170](https://github.com/moq-dev/moq/pull/4170)) + +### Other + +- rename CLAUDE.md to AGENTS.md ([#4235](https://github.com/moq-dev/moq/pull/4235)) + ## [0.1.5](https://github.com/moq-dev/moq/compare/moq-video-v0.1.4...moq-video-v0.1.5) - 2026-09-26 ### Fixed diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index ff77cebb75..7ec8601214 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.5" +version = "0.1.7" edition = "2024" rust-version.workspace = true @@ -21,7 +21,8 @@ categories = ["multimedia", "multimedia::video", "multimedia::encoding"] # on. `capture` and `v4l2` no longer cost anything on Linux (moq-v4l checks its # bindings in) and stay opt-in only until every distribution ships them. The features exist so a consumer can pick what it wants, not to hide code # from the default compile (`just check` lints default features only, so the -# opt-in ones are compiled by nightly's `just rs features` and `just rs macos`). +# opt-in ones are compiled by nightly's `just rs features` and platform.yml's +# `just rs macos`). # OpenH264 remains the default software fallback, but is optional so a hardware- # only consumer does not compile its vendored C++. Workspace consumers opt out # at the root manifest (`default-features = false`) and pick per crate, so the diff --git a/rs/moq-video/src/decode/backend/nvdec.rs b/rs/moq-video/src/decode/backend/nvdec.rs index 0a1883b45b..929776265f 100644 --- a/rs/moq-video/src/decode/backend/nvdec.rs +++ b/rs/moq-video/src/decode/backend/nvdec.rs @@ -74,6 +74,9 @@ struct State { /// An open cuvid decoder plus the output geometry it was created for. struct Decoder { api: &'static cuvid::Api, + /// Held so the context outlives the decoder and can be bound in `Drop`: + /// destroying the decoder after the last context ref is released segfaults. + ctx: Arc, handle: CUvideodecoder, /// The coded size it was created for, to detect reconfigures. coded: (u32, u32), @@ -87,10 +90,13 @@ struct Decoder { impl Drop for Decoder { fn drop(&mut self) { - // SAFETY: the handle is valid and no frame is mapped (every map is - // paired with an unmap before decode returns). The caller keeps the CUDA - // context bound. - unsafe { (self.api.destroy_decoder)(self.handle) }; + // Drop may run on any thread; destroying needs the context current. + if self.ctx.bind_to_thread().is_ok() { + // SAFETY: the handle is valid, its context is alive and current, and + // no frame is mapped (every map is paired with an unmap before decode + // returns). + unsafe { (self.api.destroy_decoder)(self.handle) }; + } } } @@ -227,8 +233,8 @@ impl Backend for Nvdec { impl Drop for Nvdec { fn drop(&mut self) { - // Drop may run on a different thread than decode; the destroy calls - // (parser here, decoder via `state`) need the context current. + // Drop may run on a different thread than decode; destroying the parser + // needs the context current (the decoder binds its own). let _ = self.state.ctx.bind_to_thread(); // SAFETY: the parser is valid and no parse call is in flight (&mut self). unsafe { (self.state.api.destroy_video_parser)(self.parser) }; @@ -350,6 +356,7 @@ impl State { ); self.decoder = Some(Decoder { api: self.api, + ctx: self.ctx.clone(), handle, coded, display_area, diff --git a/rs/moq-wasm/Cargo.toml b/rs/moq-wasm/Cargo.toml index 79f41629b9..8e72fc84a9 100644 --- a/rs/moq-wasm/Cargo.toml +++ b/rs/moq-wasm/Cargo.toml @@ -9,9 +9,9 @@ edition = "2024" publish = false [package.metadata.cargo-shear] -# Required indirectly (getrandom: feature enabler; wasm-bindgen-futures: async -# codegen) with no `use`, so cargo-shear can't see them. -ignored = ["getrandom", "wasm-bindgen-futures"] +# Required indirectly (getrandom: feature enabler) with no `use`, so cargo-shear +# can't see it. +ignored = ["getrandom"] [lib] crate-type = ["cdylib", "rlib"] @@ -26,6 +26,7 @@ crate-type = ["cdylib", "rlib"] # cfg in those crates is a prerequisite for browser media muxing. [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1" +futures = { workspace = true } getrandom = { workspace = true } # wasm_js backend for rand-via-moq-net; cfg flag in .cargo/config.toml js-sys = "0.3" moq-net = { workspace = true } diff --git a/rs/moq-wasm/src/lib.rs b/rs/moq-wasm/src/lib.rs index 9d2a392f05..c4cabe2fdf 100644 --- a/rs/moq-wasm/src/lib.rs +++ b/rs/moq-wasm/src/lib.rs @@ -11,15 +11,26 @@ //! //! `moq_net::time::run` drives moq-net with the browser clock and timer; this //! crate spawns it on the browser's microtask queue. +//! +//! Methods that wait return a `Promise` over cloned state rather than being an +//! `async fn(&self)`: wasm-bindgen keeps `&self` borrowed across such a method's +//! await, and a JS `free()` during it throws from inside Rust, which unwinds past +//! the shadow stack and corrupts the wasm heap. Freeing a handle rejects its +//! pending calls with a cancel instead; freeing the `Session` also closes it. // Browser-only crate. Empty on native so `cargo check --workspace` stays green. #![cfg(target_arch = "wasm32")] use std::cell::RefCell; +use std::pin::pin; use std::rc::Rc; -use js_sys::Uint8Array; +use futures::FutureExt; +use futures::channel::oneshot; +use futures::future::{Either, Shared, select}; +use js_sys::{Promise, Uint8Array}; use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; pub mod transport; @@ -28,6 +39,42 @@ fn js_err(e: impl std::fmt::Display) -> JsValue { JsError::new(&e.to_string()).into() } +/// Rejects a handle's pending calls once JS frees the handle. +/// +/// The handle owns the sender; each pending call races a clone of the receiver, +/// which resolves when the sender drops with the handle. +struct Freed { + _handle: oneshot::Sender<()>, + signal: Shared>, +} + +impl Freed { + fn new() -> Self { + let (handle, signal) = oneshot::channel(); + Self { + _handle: handle, + signal: signal.shared(), + } + } + + /// Run `task`, rejecting with a cancel if the handle is freed first. + /// + /// The signal is polled first, so a freed handle rejects even when `task` could + /// finish on its first poll; dropping `task` releases whatever it had taken. + fn guard(&self, task: F) -> impl Future> + use + where + F: Future>, + { + let freed = self.signal.clone(); + async move { + match select(freed, pin!(task)).await { + Either::Left(_) => Err(js_err(moq_net::Error::Cancel)), + Either::Right((result, _)) => result, + } + } + } +} + /// Install panic + tracing hooks for readable errors. Call once after the wasm /// module's default `init()` loader resolves. (Named `setup` to avoid colliding /// with wasm-bindgen's default `init` export, which loads the module itself.) @@ -96,19 +143,44 @@ impl Session { /// Reject when the session closes, with the reason it closed. /// /// Every close carries a reason, including a clean one, so this never resolves. - pub async fn closed(&self) -> Result<(), JsValue> { - Err(js_err(self.inner.closed().await)) + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn closed(&self) -> Promise { + let session = self.inner.clone(); + future_to_promise(async move { Err(js_err(session.closed().await)) }) } /// Subscribe to a broadcast by path, waiting until a route covers it. - pub async fn consume(&self, path: String) -> Result, JsValue> { - if self.consumer.routed(path.as_str()).await.is_none() { - return Ok(None); - } - match self.consumer.request_broadcast(path.as_str()).await { - Ok(inner) => Ok(Some(Broadcast { inner })), - Err(_) => Ok(None), - } + /// + /// Rejects with the close reason if the session closes first. + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn consume(&self, path: String) -> Promise { + let session = self.inner.clone(); + let consumer = self.consumer.clone(); + future_to_promise(async move { + let request = pin!(async { + consumer.routed(path.as_str()).await?; + consumer.request_broadcast(path.as_str()).await.ok() + }); + // The origin outlives the session, so its wait alone never ends on a close. + let closed = pin!(session.closed()); + match select(request, closed).await { + Either::Left((inner, _)) => Ok(inner.map_or(JsValue::UNDEFINED, |inner| { + Broadcast { + inner, + freed: Freed::new(), + } + .into() + })), + Either::Right((err, _)) => Err(js_err(err)), + } + }) + } +} + +impl Drop for Session { + // Pending calls hold their own clones, so the close-on-last-drop would wait for them. + fn drop(&mut self) { + self.inner.abort(moq_net::Error::Cancel); } } @@ -116,48 +188,60 @@ impl Session { #[wasm_bindgen] pub struct Broadcast { inner: moq_net::broadcast::Consumer, + freed: Freed, } #[wasm_bindgen] impl Broadcast { /// Subscribe to a track by name, resolving once the publisher accepts. - pub async fn subscribe(&self, name: String) -> Result { - let track = self.inner.track(&name).map_err(js_err)?; - let subscriber = track.subscribe(None).await.map_err(js_err)?; - Ok(Track { - inner: Rc::new(RefCell::new(Some(subscriber))), - }) + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn subscribe(&self, name: String) -> Promise { + let broadcast = self.inner.clone(); + future_to_promise(self.freed.guard(async move { + let track = broadcast.track(&name).map_err(js_err)?; + let subscriber = track.subscribe(None).await.map_err(js_err)?; + Ok(Track { + inner: Rc::new(RefCell::new(Some(subscriber))), + freed: Freed::new(), + } + .into()) + })) } } /// A subscriber to a single track, yielding groups. #[wasm_bindgen] pub struct Track { - // Rc>> for interior mutability: wasm-bindgen async methods - // take `&self` and must produce 'static futures, so we move the value out of - // the cell for the duration of the await rather than holding a borrow across - // it (which would make the future self-referential). One in-flight call at a - // time; a re-entrant call while one is pending errors instead of aliasing. + // Shared with the pending read, which moves the subscriber out of the cell for + // the duration of the await instead of holding a borrow across it. One read in + // flight at a time; a concurrent call errors instead of aliasing. inner: Rc>>, + freed: Freed, } #[wasm_bindgen] impl Track { /// Receive the next group in arrival order, or `null` when the track ends. - #[wasm_bindgen(js_name = recvGroup)] - pub async fn recv_group(&self) -> Result, JsValue> { + #[wasm_bindgen(js_name = recvGroup, unchecked_return_type = "Promise")] + pub fn recv_group(&self) -> Promise { let cell = self.inner.clone(); - let mut sub = cell - .borrow_mut() - .take() - .ok_or_else(|| js_err("recvGroup already in progress"))?; - let result = sub.recv_group().await; - *cell.borrow_mut() = Some(sub); - - let group = result.map_err(js_err)?; - Ok(group.map(|g| Group { - sequence: g.sequence, - inner: Rc::new(RefCell::new(Some(g))), + future_to_promise(self.freed.guard(async move { + let mut sub = cell + .borrow_mut() + .take() + .ok_or_else(|| js_err("recvGroup already in progress"))?; + let result = sub.recv_group().await; + *cell.borrow_mut() = Some(sub); + + let group = result.map_err(js_err)?; + Ok(group.map_or(JsValue::UNDEFINED, |g| { + Group { + sequence: g.sequence, + inner: Rc::new(RefCell::new(Some(g))), + freed: Freed::new(), + } + .into() + })) })) } } @@ -166,7 +250,9 @@ impl Track { #[wasm_bindgen] pub struct Group { sequence: u64, + // Shared with the pending read; see `Track`. inner: Rc>>, + freed: Freed, } #[wasm_bindgen] @@ -177,17 +263,21 @@ impl Group { } /// Read the next frame in the group, or `null` at the end of the group. - #[wasm_bindgen(js_name = readFrame)] - pub async fn read_frame(&self) -> Result, JsValue> { + #[wasm_bindgen(js_name = readFrame, unchecked_return_type = "Promise")] + pub fn read_frame(&self) -> Promise { let cell = self.inner.clone(); - let mut group = cell - .borrow_mut() - .take() - .ok_or_else(|| js_err("readFrame already in progress"))?; - let result = group.read_frame().await; - *cell.borrow_mut() = Some(group); - - let frame = result.map_err(js_err)?; - Ok(frame.map(|frame| Uint8Array::from(frame.payload.as_ref()))) + future_to_promise(self.freed.guard(async move { + let mut group = cell + .borrow_mut() + .take() + .ok_or_else(|| js_err("readFrame already in progress"))?; + let result = group.read_frame().await; + *cell.borrow_mut() = Some(group); + + let frame = result.map_err(js_err)?; + Ok(frame.map_or(JsValue::UNDEFINED, |frame| { + Uint8Array::from(frame.payload.as_ref()).into() + })) + })) } } diff --git a/rs/quest/Cargo.toml b/rs/quest/Cargo.toml deleted file mode 100644 index 0bef17e7ee..0000000000 --- a/rs/quest/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "quest" -version = "0.1.0" -edition = "2024" -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/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" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -clap.workspace = true -pulldown-cmark.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/rs/quest/src/branch.rs b/rs/quest/src/branch.rs deleted file mode 100644 index 000e039a92..0000000000 --- a/rs/quest/src/branch.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Which branch carries a quest, and which branches it merges through. -//! -//! The mapping is the path alone: a quest's branch is its path without `.md` -//! and a questline's is its README's. Milestones have no branch, so a -//! milestone's direct children merge into `main`. Nothing here asks git, so the -//! answer is the same on every machine and a missing line branch is simply -//! created from the next one in the chain. - -use std::collections::BTreeMap; -use std::path::Path; - -use anyhow::{Result, bail}; - -use crate::doc::Doc; - -/// The branch for `path`, then every branch it merges through, nearest first -/// and ending at `main`. -pub fn chain(root: &Path, path: &Path) -> Result> { - let docs = crate::load(root)?; - let by_path: BTreeMap<&Path, &Doc> = docs.iter().map(|d| (d.path.as_path(), d)).collect(); - let mut cur = crate::ready::locate(root, path, &by_path)?; - let mut out = Vec::new(); - while !Doc::permanent(&cur) { - out.push(cur.with_extension("").display().to_string()); - cur = Doc::owner(&cur).join("README.md"); - } - if out.is_empty() { - bail!("{} is the root or a milestone, which has no branch", path.display()); - } - out.push("main".to_string()); - Ok(out) -} diff --git a/rs/quest/src/doc.rs b/rs/quest/src/doc.rs deleted file mode 100644 index 11c2f0794c..0000000000 --- a/rs/quest/src/doc.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Parsing one quest document into the facts every rule reads. -//! -//! This is a real Markdown AST rather than line matching. The shell version -//! that came before it was defeated by a fence longer than three backticks -//! (which inverted its fence tracking and silently skipped the rest of the -//! file), by a `Required` bullet that wrapped onto a second line (which moved -//! the link out of reach of the check that exists to catch it), and by -//! reference-style links (which produced a rendered dependency with no edge). -//! None of those are special cases here; the parser simply reports the -//! structure that Markdown actually has. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; - -/// Where a link sits inside its `## Section`, which is what separates a -/// dependency edge from prose that merely mentions one. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Position { - /// The link opens a list item, ignoring any emphasis around it. This is the - /// shape every `Required` blocker and every `Quests` entry must have. - Entry, - /// Somewhere else inside a list item: a sentence that happens to link. - Inside, - /// Outside any list item. - Prose, -} - -/// One Markdown link, located precisely enough to be a graph edge. -#[derive(Clone, Debug)] -pub struct Link { - /// 1-based source line, for findings. - pub line: usize, - /// The enclosing `## Heading`, or `None` above the first one. - pub section: Option, - /// The destination exactly as written, fragment included. - pub target: String, - /// Where the link sits in its section; see [`Position`]. - pub position: Position, -} - -/// One top-level list entry: the shape a `Required` blocker and a `Quests` -/// index entry both have. -#[derive(Clone, Debug)] -pub struct Entry { - /// 1-based source line, for findings. - pub line: usize, - /// The enclosing `## Heading`, or `None` above the first one. - pub section: Option, - /// The rendered text with whitespace collapsed, so a bullet that wrapped - /// onto a second line reads as the one line it renders as. - pub text: String, - /// The destination of the link that opens the entry, if one does. This is - /// the [`Position::Entry`] link, so it is a dependency rather than a - /// sentence that happens to mention one. - pub target: Option, -} - -/// One `# ` or `## ` heading as the rules need to see it. -#[derive(Clone, Debug)] -pub struct Heading { - /// 1-based source line, for findings. - pub line: usize, - /// The rendered heading text, trimmed. - pub text: String, - /// The source really is `# Text` or `## Text` on its own line. Setext and - /// decorated headings render the same, but literal syntax is the contract. - pub literal: bool, -} - -/// One parsed quest document: the structure the rules read, nothing else. -#[derive(Clone, Debug)] -pub struct Doc { - /// Repository-relative, e.g. `quest/m0/one.md`. - pub path: PathBuf, - /// The document's `# ` title, used for a quest's t-shirt size. - pub title: Option, - /// `## ` headings only. Deeper levels are free-form prose structure. - pub headings: Vec, - /// Every link in the document, in source order. - pub links: Vec, - /// Every top-level list item, in source order. A heading left behind by its - /// last entry has none, and a `Required` blocker is one of these whether or - /// not it carries a link. - pub entries: Vec, -} - -impl Doc { - /// Whether the document has this exact `## ` heading. - pub fn has(&self, heading: &str) -> bool { - self.headings.iter().any(|h| h.text == heading) - } - - /// The top-level entries under a `## ` section, in source order. - pub fn entries(&self, section: &str) -> impl Iterator { - self.entries - .iter() - .filter(move |e| e.section.as_deref() == Some(section)) - } - - /// A questline is a `README.md` with a `Quests` section. Any other README is - /// what a line becomes when its last child merges: the line's own remaining - /// work, executed like any other quest. The root and the milestones are the exception. - pub fn is_questline(&self) -> bool { - self.is_readme() && (self.has("Quests") || Self::permanent(&self.path)) - } - - /// The root and the milestones outlive their quests, so an empty one is not - /// a leaf. - pub fn permanent(path: &Path) -> bool { - path.file_name().is_some_and(|n| n == "README.md") && path.components().count() <= 3 - } - - fn is_readme(&self) -> bool { - self.path.file_name().is_some_and(|n| n == "README.md") - } - - /// The questline directory this document belongs to. A questline is a - /// DIRECTORY, so its own entry sits one level further out than a quest's: - /// `quest/m1/drain/README.md` belongs to `quest/m1`, not to `quest/m1/drain`. - pub fn owner(path: &Path) -> PathBuf { - let parent = path.parent().unwrap_or(Path::new("")); - if path.file_name().is_some_and(|n| n == "README.md") { - parent.parent().unwrap_or(Path::new("")).to_path_buf() - } else { - parent.to_path_buf() - } - } - - /// Read and parse `/`; `path` stays repository-relative. - pub fn parse(root: &Path, path: PathBuf) -> Result { - let text = std::fs::read_to_string(root.join(&path)).with_context(|| format!("reading {}", path.display()))?; - Ok(Self::from_str(path, &text)) - } - - /// Parse already-loaded Markdown; this is the whole parser, `parse` just adds IO. - pub fn from_str(path: PathBuf, text: &str) -> Doc { - let lines = LineIndex::new(text); - let text_src = text; - - let mut title = None; - let mut headings = Vec::new(); - let mut links = Vec::new(); - let mut entries: Vec = Vec::new(); - let mut entry: Option = None; - let mut section: Option = None; - - // Depth of nesting, so a sub-list inside an entry does not read as a - // second entry, and so `fresh` tracks the innermost item. - let mut item_depth = 0usize; - // Entries are the flat, top-level list. A bullet nested under a prose - // lead-in, or one inside a block quote, is illustration - counting it - // would let `- evidence:` + an indented link become a real blocker. - let mut quote_depth = 0usize; - // Nothing but emphasis has been seen since the current item opened, so a - // link here is the item's opening link. `**[Blocker](/quest/b.md)**` is - // still an opener; `A customer who justifies [b](/quest/b.md)` is not. - let mut fresh = false; - let mut heading: Option<(HeadingLevel, usize, String)> = None; - - let mut options = Options::empty(); - options.insert(Options::ENABLE_STRIKETHROUGH); - options.insert(Options::ENABLE_TABLES); - - for (event, range) in Parser::new_ext(text, options).into_offset_iter() { - match event { - Event::Start(Tag::Heading { level, .. }) if matches!(level, HeadingLevel::H1 | HeadingLevel::H2) => { - heading = Some((level, lines.line_of(range.start), String::new())); - } - Event::End(TagEnd::Heading(level)) if matches!(level, HeadingLevel::H1 | HeadingLevel::H2) => { - if let Some((start_level, line, text)) = heading.take() { - debug_assert_eq!(start_level, level); - let text = text.trim().to_string(); - // Exact, not trimmed: `rg '^## Required$'` does not match a - // line with trailing spaces either, and this rule exists - // precisely so the two can never disagree. - let prefix = if level == HeadingLevel::H1 { "#" } else { "##" }; - let parsed = Heading { - line, - literal: lines.text_of(text_src, line) == format!("{prefix} {text}"), - text: text.clone(), - }; - if level == HeadingLevel::H1 { - title = Some(parsed); - } else { - section = Some(text); - headings.push(parsed); - } - } - item_depth = 0; - entry = None; - fresh = false; - } - Event::Start(Tag::BlockQuote(..)) => { - quote_depth += 1; - fresh = false; - } - Event::End(TagEnd::BlockQuote(..)) => { - quote_depth = quote_depth.saturating_sub(1); - fresh = false; - } - Event::Start(Tag::Item) => { - if item_depth == 0 && quote_depth == 0 { - entry = Some(Entry { - line: lines.line_of(range.start), - section: section.clone(), - text: String::new(), - target: None, - }); - } - item_depth += 1; - fresh = true; - } - Event::End(TagEnd::Item) => { - if item_depth == 1 - && let Some(mut done) = entry.take() - { - done.text = collapse(&done.text); - entries.push(done); - } - item_depth = item_depth.saturating_sub(1); - fresh = false; - } - Event::Start(Tag::Link { dest_url, .. }) => { - // Reference-style links arrive here already resolved to their - // destination, so they carry an edge like any other link. - // Only a top-level, unquoted item can hold an entry. - let position = if item_depth == 0 { - Position::Prose - } else if fresh && item_depth == 1 && quote_depth == 0 { - Position::Entry - } else { - Position::Inside - }; - fresh = false; - if position == Position::Entry - && let Some(entry) = entry.as_mut() - { - entry.target = Some(dest_url.to_string()); - } - links.push(Link { - line: lines.line_of(range.start), - section: section.clone(), - target: dest_url.to_string(), - position, - }); - } - // Emphasis wraps an opening link without displacing it, and so does - // the paragraph a LOOSE list puts around every item: clearing on - // its START would classify every entry in a blank-line-separated - // list as mid-sentence prose. Its END does clear, so a link that - // opens a SECOND paragraph is inside the item, not opening it. - Event::Text(ref t) | Event::Code(ref t) => { - if let Some((_, _, buf)) = heading.as_mut() { - buf.push_str(t); - } else if let Some(entry) = entry.as_mut() { - entry.text.push_str(t); - } - if !t.trim().is_empty() { - fresh = false; - } - } - Event::Start(Tag::Emphasis | Tag::Strong | Tag::Paragraph) - | Event::End(TagEnd::Emphasis | TagEnd::Strong) => {} - Event::End(TagEnd::Paragraph) => fresh = false, - Event::SoftBreak | Event::HardBreak => { - if let Some(entry) = entry.as_mut() { - entry.text.push(' '); - } - } - _ => { - if item_depth > 0 { - fresh = false; - } - } - } - } - - Doc { - path, - title, - headings, - links, - entries, - } - } -} - -/// One line of whitespace-separated words, so a wrapped or loosely indented -/// bullet prints the way it renders. -fn collapse(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} - -/// Byte offset -> 1-based line number, so findings can name a line. -struct LineIndex { - starts: Vec, -} - -impl LineIndex { - fn new(text: &str) -> LineIndex { - let mut starts = vec![0]; - starts.extend(text.match_indices('\n').map(|(i, _)| i + 1)); - LineIndex { starts } - } - - fn line_of(&self, offset: usize) -> usize { - self.starts.partition_point(|&start| start <= offset) - } - - /// The source of a 1-based line, without its newline. - fn text_of<'a>(&self, text: &'a str, line: usize) -> &'a str { - let start = self.starts.get(line - 1).copied().unwrap_or(text.len()); - let end = self.starts.get(line).copied().unwrap_or(text.len()); - text[start..end].trim_end_matches('\n') - } -} diff --git a/rs/quest/src/lib.rs b/rs/quest/src/lib.rs deleted file mode 100644 index 2845f241ac..0000000000 --- a/rs/quest/src/lib.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! 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 -//! link graph and the questline index are global, so completing one quest -//! breaks files the diff never mentions. That is not hypothetical - it is how -//! the index entry for a completed quest survived a rebase that produced no -//! conflict at all. - -pub mod branch; -pub mod doc; -pub mod ready; -pub mod rules; - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; - -pub use doc::Doc; -pub use ready::Blocker; -pub use rules::Finding; - -/// AGENTS.md is the contract rather than a quest: not executable work, so -/// neither indexed nor validated. -const NOT_QUESTS: [&str; 1] = ["AGENTS.md"]; - -/// Every quest document under `/quest`, sorted, repository-relative. -pub fn collect(root: &Path) -> Result> { - let mut out = Vec::new(); - walk(root, &root.join("quest"), &mut out).with_context(|| format!("scanning {}", root.join("quest").display()))?; - out.sort(); - Ok(out) -} - -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/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() { - walk(root, &path, out)?; - } else if path.extension().is_some_and(|e| e == "md") - && !path - .file_name() - .is_some_and(|n| NOT_QUESTS.iter().any(|skip| n == *skip)) - { - out.push(path.strip_prefix(root).unwrap_or(&path).to_path_buf()); - } - } - Ok(()) -} - -/// Parse and validate the tree. Returns every finding, worst-case empty. -pub fn check(root: &Path) -> Result> { - Ok(rules::check(root, &load(root)?)) -} - -/// Every quest document, parsed, in tree order. -fn load(root: &Path) -> Result> { - let paths = collect(root)?; - if paths.is_empty() { - bail!("no quest documents found under {}", root.join("quest").display()); - } - paths.into_iter().map(|p| Doc::parse(root, p)).collect() -} diff --git a/rs/quest/src/main.rs b/rs/quest/src/main.rs deleted file mode 100644 index 27135981cd..0000000000 --- a/rs/quest/src/main.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::PathBuf; -use std::process::ExitCode; - -use anyhow::Result; -use clap::{Parser, Subcommand}; - -/// The quest tree, the interlinked Markdown plans under quest/ whose contract is -/// quest/AGENTS.md: validate its structure, report what blocks a quest, or name -/// the branches a quest lands on. -#[derive(Parser)] -#[command(version, about)] -struct Cli { - /// Repository root holding the quest/ directory. - #[arg(long, default_value = ".", global = true)] - root: PathBuf, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Report every structural mistake in the tree; exit non-zero if any. - Check, - - /// Print what blocks a quest, or list every ready quest. - /// - /// Exits 0 either way: the blocker list on stdout is the result, so no - /// output means ready and a caller tests that rather than parsing prose. A - /// non-zero exit means the command itself failed. - Ready { - /// Quest to explain. Omit to list every ready quest in tree order. - path: Option, - }, - - /// Print the branch carrying a quest, then every branch it merges through. - /// - /// One per line, nearest first, ending at `main`: a quest merges into its - /// questline's branch, a questline into its parent's, and a milestone's - /// children into `main`. A line missing from the remote is created from the - /// one printed after it. - Branch { - /// Quest or questline to locate. - path: PathBuf, - }, -} - -fn main() -> Result { - let cli = Cli::parse(); - match cli.command { - Command::Check => { - let findings = quest::check(&cli.root)?; - if findings.is_empty() { - let total = quest::collect(&cli.root)?.len(); - println!("quest: {total} documents ok"); - return Ok(ExitCode::SUCCESS); - } - for finding in &findings { - eprintln!("quest: {finding}"); - } - Ok(ExitCode::FAILURE) - } - Command::Ready { path: Some(path) } => { - let blockers = quest::ready::blockers(&cli.root, &path)?; - for blocker in &blockers { - print!("{blocker}"); - } - if !blockers.is_empty() { - // 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/AGENTS.md, Creation) rather than starting this one as it stands", - path.display() - ); - } - Ok(ExitCode::SUCCESS) - } - Command::Ready { path: None } => { - for path in quest::ready::quests(&cli.root)? { - println!("{}", path.display()); - } - Ok(ExitCode::SUCCESS) - } - Command::Branch { path } => { - for branch in quest::branch::chain(&cli.root, &path)? { - println!("{branch}"); - } - Ok(ExitCode::SUCCESS) - } - } -} diff --git a/rs/quest/src/ready.rs b/rs/quest/src/ready.rs deleted file mode 100644 index 62ade835ee..0000000000 --- a/rs/quest/src/ready.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Whether a quest can be started, read from the same `Required` sections the -//! rules validate. -//! -//! Readiness is a property of the tree alone, which is what makes it cheap and -//! deterministic: a finished quest is deleted, so a blocker that still resolves -//! is still open. Liveness is the other question - a quest can be ready, -//! coherent, and already done by some other PR - and answering it means asking -//! GitHub, so it belongs to the flow that is already talking to it. - -use std::collections::BTreeMap; -use std::fmt; -use std::path::{Path, PathBuf}; - -use anyhow::{Result, bail}; - -use crate::doc::Doc; -use crate::rules; - -/// One thing standing between a quest and being started. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Blocker { - /// The quest or questline that has to finish first, when the blocker is one - /// of ours. `None` is a plain-text condition, which nothing in the tree can - /// ever clear. - pub path: Option, - /// The `Required` bullet as written, whitespace collapsed. - pub text: String, - /// The still-open quests under a required questline, which is what a - /// questline blocker actually means. Empty for every other blocker: a - /// required quest's own blockers are its readiness, not this one's. - pub blockers: Vec, -} - -impl Blocker { - /// What names the blocker: the document, or the condition's own words. - pub fn label(&self) -> String { - match &self.path { - Some(path) => path.display().to_string(), - None => self.text.clone(), - } - } - - fn write(&self, f: &mut fmt::Formatter<'_>, depth: usize) -> fmt::Result { - writeln!(f, "{}{}", " ".repeat(depth), self.label())?; - for blocker in &self.blockers { - blocker.write(f, depth + 1)?; - } - Ok(()) - } -} - -impl fmt::Display for Blocker { - /// The whole chain, one blocker per line and indented by depth, newline - /// included: a caller prints these back to back. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.write(f, 0) - } -} - -/// What blocks `path`, a required questline expanded into the quests it still -/// holds. Empty means ready. -/// -/// `path` is the quest as the tree writes it (`/quest/m0/one.md`), as the shell -/// completes it (`quest/m0/one.md`), or as an absolute filesystem path. -pub fn blockers(root: &Path, path: &Path) -> Result> { - let docs = crate::load(root)?; - let by_path: BTreeMap<&Path, &Doc> = docs.iter().map(|d| (d.path.as_path(), d)).collect(); - let path = locate(root, path, &by_path)?; - Ok(expand(&by_path, by_path[path.as_path()], &mut vec![path.clone()])) -} - -/// Every quest that can be started now, in tree order. -/// -/// 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/AGENTS.md -/// defines as ready. -pub fn quests(root: &Path) -> Result> { - let docs = crate::load(root)?; - let mut remaining: BTreeMap = docs.iter().map(|doc| (doc.path.clone(), doc)).collect(); - let mut pending = vec![PathBuf::from("quest/README.md")]; - let mut ready = Vec::new(); - while let Some(path) = pending.pop() { - let Some(doc) = remaining.remove(&path) else { continue }; - if doc.is_questline() { - let children: Vec<_> = doc - .entries("Quests") - .filter_map(|entry| entry.target.as_deref().and_then(rules::rooted)) - .collect(); - pending.extend(children.into_iter().rev()); - } else if !doc.has("Required") { - ready.push(path); - } - } - ready.extend( - remaining - .into_iter() - .filter(|(_, doc)| !doc.is_questline() && !doc.has("Required")) - .map(|(path, _)| path), - ); - Ok(ready) -} - -/// The blockers of one document: a quest waits on its `Required` entries, and a -/// questline is complete only when all of its quests are, so it waits on those. -fn expand(by_path: &BTreeMap<&Path, &Doc>, doc: &Doc, stack: &mut Vec) -> Vec { - let section = if doc.is_questline() { "Quests" } else { "Required" }; - - // A heading left standing after its last blocker still reads as blocked to - // everything that greps for it, including `quest check`, which reports it. - // Calling it ready here would make this the one tool that disagrees. - if !doc.is_questline() && doc.has("Required") && doc.entries("Required").next().is_none() { - return vec![Blocker { - path: None, - text: "an empty '## Required' section, which blocks the quest until the heading is removed".to_string(), - blockers: Vec::new(), - }]; - } - - doc.entries(section) - .map(|entry| blocker(by_path, entry, stack)) - .collect() -} - -fn blocker(by_path: &BTreeMap<&Path, &Doc>, entry: &crate::doc::Entry, stack: &mut Vec) -> Blocker { - // A bullet that does not open with a link into the tree is a plain-text - // condition: an issue, a release, a customer. Nothing here can clear it, so - // it is a blocker with nothing under it. - let path = entry - .target - .as_deref() - .and_then(rules::rooted) - .filter(|path| by_path.contains_key(path.as_path())); - - // Only a questline expands. A required QUEST is the blocker itself, and its - // own chain is the answer to running this on that quest instead; printing - // it here buries the entries that were asked for under a repeated subtree. - // The stack guard is for a tree nobody has run `quest check` on yet, where a - // questline listing an ancestor must print rather than recurse forever. - let blockers = match &path { - Some(path) if by_path[path.as_path()].is_questline() && !stack.contains(path) => { - stack.push(path.clone()); - let blockers = expand(by_path, by_path[path.as_path()], stack); - stack.pop(); - blockers - } - _ => Vec::new(), - }; - - Blocker { - path, - text: entry.text.clone(), - blockers, - } -} - -/// Resolve a quest path the way a caller is likely to have it to the -/// repository-relative one the tree is keyed on. -pub(crate) fn locate(root: &Path, path: &Path, by_path: &BTreeMap<&Path, &Doc>) -> Result { - let mut candidates = vec![rules::normalize(path)]; - if let Some(rooted) = path.to_str().and_then(|p| p.strip_prefix('/')) { - candidates.push(rules::normalize(Path::new(rooted))); - } - if let (Ok(absolute), Ok(root)) = (path.canonicalize(), root.canonicalize()) - && let Ok(relative) = absolute.strip_prefix(root) - { - candidates.push(relative.to_path_buf()); - } - - match candidates.into_iter().find(|c| by_path.contains_key(c.as_path())) { - Some(found) => Ok(found), - None => bail!( - "{} is not a quest document under {}", - path.display(), - root.join("quest").display() - ), - } -} diff --git a/rs/quest/src/rules.rs b/rs/quest/src/rules.rs deleted file mode 100644 index 923865ecf2..0000000000 --- a/rs/quest/src/rules.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! The rules, and the findings they produce. -//! -//! 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. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; - -use crate::doc::{Doc, Position}; - -/// The `## ` headings a quest document may use. Readiness greps `## Required` -/// literally, so a typo turns a blocked quest ready and fails nowhere else: -/// the closed vocabulary is what catches it. -const HEADINGS: [&str; 6] = ["Goal", "Plan", "Required", "Closes", "Related", "Quests"]; -const SIZES: [&str; 5] = ["XS", "S", "M", "L", "XL"]; - -/// Sections whose whole content is a list. A heading left standing after its -/// last entry was removed is a bug in both directions: an empty `Required` -/// blocks its quest forever, and an empty `Quests` leaves a questline that -/// should have been deleted with its last quest. -/// `Quests` is deliberately absent: a bullet is not an entry (`- TBD` is a list -/// item with no quest in it), so its emptiness is decided by counting valid -/// entries in `index` instead. -const LIST_SECTIONS: [&str; 3] = ["Required", "Closes", "Related"]; - -/// The permanent root questline; the one document nothing has to list. -pub const ROOT: &str = "quest/README.md"; - -/// One violation, addressed like a compiler diagnostic: `path:line: message`. -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct Finding { - /// Repository-relative document the violation is in. - pub path: PathBuf, - /// 1-based line, when the violation has one; `None` for whole-file findings. - pub line: Option, - /// What is wrong, and often why the rule exists. - pub message: String, -} - -impl std::fmt::Display for Finding { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.line { - Some(line) => write!(f, "{}:{}: {}", self.path.display(), line, self.message), - None => write!(f, "{}: {}", self.path.display(), self.message), - } - } -} - -struct Findings(Vec); - -impl Findings { - fn at(&mut self, path: &Path, line: usize, message: impl Into) { - self.0.push(Finding { - path: path.to_path_buf(), - line: Some(line), - message: message.into(), - }); - } - - fn on(&mut self, path: &Path, message: impl Into) { - self.0.push(Finding { - path: path.to_path_buf(), - line: None, - message: message.into(), - }); - } -} - -/// `root` is the repository root; every path in `docs` is relative to it. -pub fn check(root: &Path, docs: &[Doc]) -> Vec { - let mut found = Findings(Vec::new()); - let known: BTreeSet<&Path> = docs.iter().map(|d| d.path.as_path()).collect(); - - for doc in docs { - headings(&mut found, doc); - links(&mut found, root, &known, doc); - } - - let index = index(&mut found, &known, docs); - cycles(&mut found, docs, &index); - - found.0.sort(); - found.0 -} - -fn headings(found: &mut Findings, doc: &Doc) { - if !doc.is_questline() { - let valid = doc.title.as_ref().is_some_and(|title| { - let Some((size, name)) = title.text.strip_prefix('[').and_then(|s| s.split_once("] ")) else { - return false; - }; - title.literal && SIZES.contains(&size) && !name.is_empty() - }); - if !valid { - found.on(&doc.path, "quest title must be '# [XS|S|M|L|XL] Title'"); - } - } - - if !doc.has("Goal") { - found.on(&doc.path, "missing '## Goal'"); - } - - for heading in &doc.headings { - // A setext underline and a decorated ``## `Required` `` both render as - // the same heading, and this validator would treat the quest as blocked. - // Readiness greps `^## Required$` literally and would call it READY. - if HEADINGS.contains(&heading.text.as_str()) && !heading.literal { - found.at( - &doc.path, - heading.line, - format!( - "'{}' must be written literally as '## {}'; readiness greps that form and would not see this one", - heading.text, heading.text - ), - ); - } - if !HEADINGS.contains(&heading.text.as_str()) { - found.at( - &doc.path, - heading.line, - format!("unknown '## {}' (allowed: {})", heading.text, HEADINGS.join(", ")), - ); - } - } - - // Only a README indexes children; a quest is a leaf and must not, since the - // index is what makes a file a questline and questlines are not picked up. - if doc.has("Quests") && !doc.is_questline() { - found.on(&doc.path, "only a README may have '## Quests'"); - } - for heading in &doc.headings { - if LIST_SECTIONS.contains(&heading.text.as_str()) && doc.entries(&heading.text).next().is_none() { - let why = match heading.text.as_str() { - "Required" => "; an empty one blocks the quest forever, so remove the heading with its last entry", - "Quests" => "; a questline with no quests left should be deleted", - _ => "; remove the heading with its last entry", - }; - found.at(&doc.path, heading.line, format!("'## {}' is empty{why}", heading.text)); - } - } -} - -/// Strip a `#fragment`, which addresses a place inside a file rather than a -/// different file. Leaving it on made a phantom graph node that no quest could -/// ever match, so a cycle through an anchored link went unreported. -fn without_fragment(target: &str) -> &str { - target.split('#').next().unwrap_or(target) -} - -/// A root-absolute target as a repository-relative path, or `None` if it is not -/// root-absolute. Fragment-stripped AND normalized: the index and the cycle walk -/// both key on this, and a `..` left in one of them is a node nothing matches. -pub(crate) fn rooted(target: &str) -> Option { - target - .strip_prefix('/') - .map(|r| normalize(Path::new(without_fragment(r)))) -} - -fn resolve(doc_path: &Path, target: &str) -> PathBuf { - match target.strip_prefix('/') { - Some(rooted) => normalize(Path::new(rooted)), - None => normalize(&doc_path.parent().unwrap_or(Path::new("")).join(target)), - } -} - -/// Collapse `..` textually. `Path::canonicalize` would need the file to exist, -/// which is the very thing being tested. -pub(crate) fn normalize(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for part in path.components() { - match part { - std::path::Component::ParentDir => { - // Popping unconditionally let `../..` cancel itself, so a link - // with too many `..` climbed above the root and then walked back - // down to a real file. - if out.components().next_back() == Some(std::path::Component::ParentDir) || !out.pop() { - out.push(".."); - } - } - std::path::Component::CurDir => {} - other => out.push(other.as_os_str()), - } - } - out -} - -fn links(found: &mut Findings, root: &Path, known: &BTreeSet<&Path>, doc: &Doc) { - for link in &doc.links { - if link.target.contains("://") || link.target.starts_with("mailto:") { - continue; - } - let target = without_fragment(&link.target); - if target.is_empty() { - continue; - } - - // A normalized path that still opens with `..` points above the - // repository root. Joining it to `root` and testing existence would - // follow it into whatever sits beside the checkout, so the repo's own - // directory name (or a sibling worktree) could make a broken link pass. - let path = resolve(&doc.path, target); - if path.starts_with("..") || !root.join(&path).exists() { - found.at(&doc.path, link.line, format!("link does not resolve: {}", link.target)); - continue; - } - - // Quests and questlines reference each other with root-absolute links. - // A relative one still renders, so nothing else would notice - but it is - // invisible to the dependency graph below, which only speaks /quest/... - if known.contains(path.as_path()) && !link.target.starts_with('/') { - found.at( - &doc.path, - link.line, - format!( - "link to a quest must be root-absolute: {} (write /{})", - link.target, - path.display() - ), - ); - } - - // A `Required` bullet is either a dependency edge (the link opens it) or - // a plain-text external condition (no quest link at all). - // moq-dev/moq.pro#1170 shipped the third shape: a customer-gate sentence - // mentioning a questline mid-line, which reads as context but IS a - // blocker, and so silently required all of m2. - if link.section.as_deref() == Some("Required") - && known.contains(path.as_path()) - && link.position != Position::Entry - { - found.at( - &doc.path, - link.line, - format!( - "Required links {} mid-sentence; that reads as prose but IS a blocker - open the bullet with the link, or drop the link", - link.target - ), - ); - } - } -} - -/// Which questline lists each document. The index must be exactly the file -/// tree: every document is listed by the questline it sits under, and a -/// questline lists nothing but its own children - together these also give -/// "listed by exactly one questline". -fn index<'a>(found: &mut Findings, known: &BTreeSet<&Path>, docs: &'a [Doc]) -> BTreeMap { - let mut listed: BTreeMap = BTreeMap::new(); - let mut entries: BTreeMap<&Path, usize> = BTreeMap::new(); - - for doc in docs { - for link in &doc.links { - if link.section.as_deref() != Some("Quests") { - continue; - } - // The index is a list of entries, not prose that happens to link: - // `See [One](/quest/m0/one.md)` must not make One look indexed. - if link.position != Position::Entry { - found.at( - &doc.path, - link.line, - format!("a Quests entry must open its bullet: {}", link.target), - ); - continue; - } - let Some(child) = rooted(&link.target) else { - found.at( - &doc.path, - link.line, - format!( - "a Quests entry must be a root-absolute /quest/... link: {}", - link.target - ), - ); - continue; - }; - // Existence is not enough: the index points readers at work to pick - // 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, - link.line, - format!("lists {}, which is not a quest document", link.target), - ); - continue; - } - if Doc::owner(&child) != doc.path.parent().unwrap_or(Path::new("")) { - found.at( - &doc.path, - link.line, - format!("lists {}, which does not sit under this questline", link.target), - ); - continue; - } - if listed.insert(child, doc.path.as_path()).is_some() { - found.at(&doc.path, link.line, format!("lists {} twice", link.target)); - } - *entries.entry(doc.path.as_path()).or_default() += 1; - } - } - - for doc in docs { - // A questline lists at least one quest. Completing its last one is - // supposed to delete the directory; a heading holding a bullet with no - // quest in it (`- TBD`) leaves the husk standing just as well as a bare - // one does. - if doc.is_questline() && doc.has("Quests") && entries.get(doc.path.as_path()).copied().unwrap_or(0) == 0 { - found.on( - &doc.path, - "'## Quests' lists no quest; a questline with none left should be deleted", - ); - } - - // The root questline is permanent and has nothing above it to list it. - if doc.path == Path::new(ROOT) || listed.contains_key(&doc.path) { - continue; - } - let owner = Doc::owner(&doc.path).join("README.md"); - found.on( - &doc.path, - format!( - "not listed in {}'s '## Quests'; an unlisted quest is unreachable", - owner.display() - ), - ); - } - - listed -} - -/// `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 -/// 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(); - - for doc in docs { - let edges: Vec = doc - .links - .iter() - .filter(|l| l.section.as_deref() == Some("Required") && l.position == Position::Entry) - .filter_map(|l| rooted(&l.target)) - .collect(); - blockers.entry(doc.path.as_path()).or_default().extend(edges); - } - - // A quest may require a whole QUESTLINE, and a questline is complete only - // when all of its quests are, so a questline waits on its own children. - // Without these edges the walk stops at the README and misses the deadlock - // that spans it: a quest requiring the questline that holds the quest - // requiring it back. - for (child, questline) in listed { - blockers.entry(questline).or_default().push(child.clone()); - } - - #[derive(Clone, Copy, PartialEq)] - enum State { - Open, - Done, - } - - fn walk( - node: &Path, - blockers: &BTreeMap<&Path, Vec>, - state: &mut BTreeMap, - stack: &mut Vec, - found: &mut Findings, - ) { - match state.get(node) { - Some(State::Done) => return, - Some(State::Open) => { - let from = stack.iter().position(|p| p == node).unwrap_or(0); - let mut path: Vec = stack[from..].iter().map(|p| p.display().to_string()).collect(); - path.push(node.display().to_string()); - found.on(node, format!("Required cycle: {}", path.join(" -> "))); - return; - } - None => {} - } - state.insert(node.to_path_buf(), State::Open); - stack.push(node.to_path_buf()); - for next in blockers.get(node).map(Vec::as_slice).unwrap_or_default() { - walk(next, blockers, state, stack, found); - } - stack.pop(); - state.insert(node.to_path_buf(), State::Done); - } - - let mut state = BTreeMap::new(); - for doc in docs { - walk(&doc.path, &blockers, &mut state, &mut Vec::new(), found); - } -} diff --git a/rs/quest/tests/tree.rs b/rs/quest/tests/tree.rs deleted file mode 100644 index e5577755a3..0000000000 --- a/rs/quest/tests/tree.rs +++ /dev/null @@ -1,871 +0,0 @@ -//! Every rule, each proven to actually fail, and the readiness the same tree -//! answers. -//! -//! A validator that silently stopped enforcing a rule is indistinguishable from -//! a clean tree, so each case starts from the same valid fixture and breaks -//! exactly one thing. The cases that must still PASS matter just as much: the -//! shell version this replaced was rewritten precisely because it rejected -//! legitimate Markdown and accepted the shapes it was written to catch. - -use std::path::Path; - -use tempfile::TempDir; - -const ROOT_README: &str = "\ -# Quests - -## Goal - -The permanent root questline. - -## Quests - -- [m0](/quest/m0/README.md) -"; - -const M0_README: &str = "\ -# m0 - -## Goal - -A milestone. - -## Quests - -- [Line](/quest/m0/line/README.md) -"; - -const LINE_README: &str = "\ -# Line - -## Goal - -A questline. - -## Quests - -- [One](/quest/m0/line/one.md) -- [Two](/quest/m0/line/two.md) -"; - -const ONE: &str = "\ -# [S] One - -## Goal - -A quest. -"; - -const TWO: &str = "\ -# [S] Two - -## Goal - -Another quest. - -## Required - -- [One](/quest/m0/line/one.md) - must finish first -"; - -/// A minimal but complete tree: root questline -> milestone -> questline -> two -/// quests, one blocking the other. -struct Tree(TempDir); - -impl Tree { - fn new() -> Tree { - let tree = Tree(TempDir::new().expect("tempdir")); - tree.write("quest/README.md", ROOT_README); - tree.write("quest/m0/README.md", M0_README); - tree.write("quest/m0/line/README.md", LINE_README); - tree.write("quest/m0/line/one.md", ONE); - tree.write("quest/m0/line/two.md", TWO); - tree - } - - fn path(&self) -> &Path { - self.0.path() - } - - fn write(&self, rel: &str, body: &str) -> &Tree { - let path = self.path().join(rel); - std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir"); - std::fs::write(path, body).expect("write"); - self - } - - fn append(&self, rel: &str, body: &str) -> &Tree { - let path = self.path().join(rel); - let existing = std::fs::read_to_string(&path).expect("read"); - std::fs::write(path, format!("{existing}{body}")).expect("write"); - self - } - - fn findings(&self) -> Vec { - quest::check(self.path()) - .expect("check") - .iter() - .map(ToString::to_string) - .collect() - } - - /// The rendered blocker chain: one line per blocker, nesting indented. - fn blockers(&self, path: &str) -> Vec { - quest::ready::blockers(self.path(), Path::new(path)) - .expect("blockers") - .iter() - .flat_map(|blocker| blocker.to_string().lines().map(str::to_owned).collect::>()) - .collect() - } - - fn ready(&self) -> Vec { - quest::ready::quests(self.path()) - .expect("ready") - .iter() - .map(|path| path.display().to_string()) - .collect() - } - - #[track_caller] - fn accepts(&self) { - let findings = self.findings(); - assert!(findings.is_empty(), "expected the tree to pass, got: {findings:#?}"); - } - - #[track_caller] - fn without(&self, unexpected: &str) { - let findings = self.findings(); - assert!( - !findings.iter().any(|f| f.contains(unexpected)), - "expected NO finding containing {unexpected:?}, got: {findings:#?}" - ); - } - - #[track_caller] - fn rejects(&self, expected: &str) { - let findings = self.findings(); - assert!( - findings.iter().any(|f| f.contains(expected)), - "expected a finding containing {expected:?}, got: {findings:#?}" - ); - } -} - -/// The fixture itself has to pass, or every case below proves nothing. -#[test] -fn baseline_is_valid() { - Tree::new().accepts(); -} - -#[test] -fn dangling_absolute_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Related\n\n- [Gone](/quest/m0/line/gone.md) - completed and deleted\n", - ); - tree.rejects("link does not resolve: /quest/m0/line/gone.md"); -} - -/// 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("AGENTS.md", "# Guide\n"); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../AGENTS.md).\n", - ); - tree.accepts(); -} - -#[test] -fn relative_link_above_the_repository_root() { - let tree = Tree::new(); - 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](../../../../AGENTS.md).\n", - ); - tree.rejects("link does not resolve: ../../../../AGENTS.md"); -} - -/// Quests reference each other root-absolutely. A relative one renders fine, so -/// nothing else would notice - but it is invisible to the dependency graph. -#[test] -fn relative_link_to_a_quest() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Related\n\n- [Two](two.md) - a sibling\n"); - 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 AGENTS.md's own -/// example would make this a check everyone learns to skip. -#[test] -fn fenced_templates_are_not_links() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\n```markdown\n## Required\n\n- [Blocker](/quest/foo/bar.md) - must finish first\n```\n", - ); - tree.accepts(); -} - -/// A fence longer than three backticks may contain shorter ones, and `~~~` is a -/// fence too. Miscounting either inverts the fence state and silently skips the -/// REST OF THE FILE - the worst failure this tool has, because it looks clean. -#[test] -fn nested_and_tilde_fences_do_not_leak() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\n````markdown\n```bash\njust check\n```\n````\n\n~~~text\n```\n~~~\n\n## Requires\n\n- [Gone](/quest/m0/line/gone.md) - typo'd heading and a dangling link\n", - ); - tree.rejects("unknown '## Requires'"); - tree.rejects("link does not resolve: /quest/m0/line/gone.md"); -} - -#[test] -fn missing_goal() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/one.md", - "# [S] One\n\n## Plan\n\nA quest with no stated outcome.\n", - ); - tree.rejects("missing '## Goal'"); -} - -#[test] -fn quest_title_needs_a_size() { - let tree = Tree::new(); - tree.write("quest/m0/line/one.md", &ONE.replace("# [S] One", "# One")); - tree.rejects("quest title must be '# [XS|S|M|L|XL] Title'"); -} - -#[test] -fn quest_title_accepts_xl() { - let tree = Tree::new(); - tree.write("quest/m0/line/one.md", &ONE.replace("# [S] One", "# [XL] One")); - tree.accepts(); -} - -#[test] -fn quest_title_rejects_xxl() { - let tree = Tree::new(); - tree.write("quest/m0/line/one.md", &ONE.replace("# [S] One", "# [XXL] One")); - tree.rejects("quest title must be '# [XS|S|M|L|XL] Title'"); -} - -/// The closed vocabulary exists for this: readiness greps `## Required` -/// literally, so a typo makes a blocked quest read as ready and fails nowhere. -#[test] -fn typo_in_a_heading() { - let tree = Tree::new(); - tree.write("quest/m0/line/two.md", &TWO.replace("## Required", "## Requires")); - tree.rejects("unknown '## Requires'"); -} - -#[test] -fn quest_with_a_questline_index() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Quests\n\n- [Two](/quest/m0/line/two.md)\n", - ); - tree.rejects("only a README may have '## Quests'"); -} - -/// A README whose last child merged is the line's own remaining work: a leaf -/// quest, sized and listed as ready like any other. -#[test] -fn readme_without_an_index_is_a_quest() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/sub/README.md", - "# Sub\n\n## Goal\n\nThe end-to-end test once every child has merged.\n", - ); - tree.append("quest/m0/line/README.md", "- [Sub](/quest/m0/line/sub/README.md)\n"); - tree.rejects("quest title must be"); - tree.write( - "quest/m0/line/sub/README.md", - "# [S] Sub\n\n## Goal\n\nThe end-to-end test once every child has merged.\n", - ); - tree.accepts(); - assert!(tree.ready().contains(&"quest/m0/line/sub/README.md".to_string())); -} - -/// Completing a questline's last quest deletes the directory. A bare `## Quests` -/// heading otherwise satisfies the questline rule and leaves the husk standing. -/// (The other direction - a heading holding a non-quest bullet - is -/// `questline_listing_no_quest`; one rule, both shapes.) -#[test] -fn empty_questline_index() { - let tree = Tree::new(); - tree.write( - "quest/m0/husk/README.md", - "# Husk\n\n## Goal\n\nIts last quest was completed.\n\n## Quests\n", - ); - tree.append("quest/m0/README.md", "- [Husk](/quest/m0/husk/README.md)\n"); - tree.rejects("lists no quest"); -} - -/// The absence of `## Required` means ready. A heading left behind by its last -/// blocker reads as blocked to every readiness check, forever. -#[test] -fn empty_required_section() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Required\n"); - tree.rejects("'## Required' is empty"); -} - -#[test] -fn unlisted_quest() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/three.md", - "# [S] Three\n\n## Goal\n\nA quest nobody indexed.\n", - ); - tree.rejects("not listed in quest/m0/line/README.md's '## Quests'"); -} - -/// Quests are indexed where they sit, so a milestone cannot reach past its own -/// questlines to list a grandchild. -#[test] -fn questline_listing_a_grandchild() { - let tree = Tree::new(); - tree.append("quest/m0/README.md", "- [One](/quest/m0/line/one.md)\n"); - tree.rejects("does not sit under this questline"); -} - -#[test] -fn quest_listed_twice() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "- [One again](/quest/m0/line/one.md)\n"); - tree.rejects("lists /quest/m0/line/one.md twice"); -} - -#[test] -fn relative_index_entry() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/README.md", - &LINE_README.replace("(/quest/m0/line/one.md)", "(one.md)"), - ); - tree.rejects("must be a root-absolute /quest/... link: one.md"); -} - -/// The index points readers at work to pick up, so a target that merely exists -/// 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/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. -#[test] -fn prose_under_the_index() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "\nSee also [One](/quest/m0/line/one.md).\n"); - tree.rejects("a Quests entry must open its bullet"); -} - -#[test] -fn direct_required_cycle() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -/// A quest may require a whole questline, so the deadlock can span the README. -/// The cycle here runs strictly OUTSIDE-IN: `outer` (in m0) requires the line -/// questline, and `three` inside that questline requires `outer` back. No quest -/// requires its own questline, so containment edges are the only thing that can -/// close it. -#[test] -fn cycle_through_a_questline() { - let tree = Tree::new(); - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write( - "quest/m0/outer.md", - "# [S] Outer\n\n## Goal\n\nBlocked on a whole questline.\n\n## Required\n\n- [Line](/quest/m0/line/README.md) - the whole questline must finish\n", - ); - tree.append("quest/m0/line/README.md", "- [Three](/quest/m0/line/three.md)\n"); - tree.write( - "quest/m0/line/three.md", - "# [S] Three\n\n## Goal\n\nInside the questline that blocks it.\n\n## Required\n\n- [Outer](/quest/m0/outer.md) - must finish first\n", - ); - tree.rejects( - "Required cycle: quest/m0/line/README.md -> quest/m0/line/three.md -> quest/m0/outer.md -> quest/m0/line/README.md", - ); -} - -/// A `#fragment` addresses a place inside a file, not a different file. Keeping -/// it on made a phantom graph node that no quest could match, so a cycle through -/// an anchored link went unreported. -#[test] -fn cycle_through_an_anchored_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/two.md#plan) - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -/// Reference-style links render as real dependencies, so they must carry real -/// edges. A line-oriented parser saw no `](` here and produced none. -#[test] -fn cycle_through_a_reference_style_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two][two] - must finish first\n\n[two]: /quest/m0/line/two.md\n", - ); - tree.rejects("Required cycle:"); -} - -/// moq-dev/moq.pro#1170: a plain-text external condition that happens to link a -/// questline mid-sentence reads as context but IS a dependency edge. -#[test] -fn required_link_mid_sentence() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who also justifies [the line](/quest/m0/line/README.md).\n", - ); - tree.rejects("mid-sentence"); -} - -/// The same failure, reflowed onto a second line. The link is no longer on the -/// bullet's first line, which is all it took to slip past the shell version. -#[test] -fn required_link_on_a_wrapped_bullet() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who also justifies\n [the line](/quest/m0/line/README.md).\n", - ); - tree.rejects("mid-sentence"); -} - -/// The other half of that rule: an external condition with no link at all is the -/// shape AGENTS.md prescribes, and must stay legal. -#[test] -fn required_external_condition() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who justifies the work.\n", - ); - tree.accepts(); -} - -/// A LOOSE list - blank lines between entries - wraps every item in a paragraph. -/// Treating that paragraph as text would classify every entry in the list as -/// mid-sentence prose, which is a false positive on ordinary Markdown and would -/// fire on every blocker and every index entry at once. -#[test] -fn loose_index_list() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/README.md", - "# Line\n\n## Goal\n\nA questline.\n\n## Quests\n\n- [One](/quest/m0/line/one.md)\n\n- [Two](/quest/m0/line/two.md)\n", - ); - tree.accepts(); -} - -#[test] -fn loose_required_list() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n\n- A customer who justifies the work.\n", - ); - // The edge registered (hence the cycle) without reading as prose. - tree.rejects("Required cycle:"); - tree.without("mid-sentence"); -} - -/// The other side of the same seam: a link opening a SECOND paragraph is inside -/// the item, not opening it. -#[test] -fn required_link_in_a_later_paragraph() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A customer who justifies the work.\n\n [The line](/quest/m0/line/README.md) would follow.\n", - ); - tree.rejects("mid-sentence"); -} - -/// A blocker written with emphasis still opens its bullet. Rejecting it would -/// be a false positive on legitimate Markdown. -#[test] -fn required_link_with_emphasis() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- **[Two](/quest/m0/line/two.md)** - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -/// A setext underline renders as an H2 and this validator would read the quest -/// as blocked - but readiness greps `^## Required$` and would call it READY. -/// Two tools disagreeing about the same file is the whole failure. -#[test] -fn setext_heading() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\nRequired\n--------\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("must be written literally as '## Required'"); -} - -#[test] -fn decorated_heading() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## `Required`\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("must be written literally as '## Required'"); -} - -/// A bullet nested under a prose lead-in is illustration, not a blocker. Reading -/// it as one is #1170 again, wearing an indent. -#[test] -fn nested_required_entry() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- Customer evidence:\n - [The line](/quest/m0/line/README.md)\n", - ); - tree.rejects("mid-sentence"); -} - -#[test] -fn blockquoted_required_entry() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- Quoting the old plan:\n\n > - [The line](/quest/m0/line/README.md)\n", - ); - tree.rejects("mid-sentence"); -} - -/// Repeated `..` must not cancel each other on the way up. Popping -/// unconditionally let a link climb above the root and walk back down to a real -/// file, so a badly flattened path resolved and reported nothing. -#[test] -fn repeated_parent_components() { - let tree = Tree::new(); - tree.write("AGENTS.md", "# Guide\n"); - tree.append( - "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../../../AGENTS.md).\n", - ); - 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 `/..//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("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}/AGENTS.md).\n"), - ); - tree.rejects(&format!("link does not resolve: ../../../../{name}/AGENTS.md")); -} - -/// A bullet is not an entry. `- TBD` satisfies "the heading has a list" while -/// leaving a questline that should have been deleted with its last quest. -#[test] -fn questline_listing_no_quest() { - let tree = Tree::new(); - tree.write( - "quest/m0/husk/README.md", - "# Husk\n\n## Goal\n\nIts last quest was completed.\n\n## Quests\n\n- TBD\n", - ); - tree.append("quest/m0/README.md", "- [Husk](/quest/m0/husk/README.md)\n"); - tree.rejects("lists no quest"); -} - -#[test] -fn empty_related_section() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Related\n"); - tree.rejects("'## Related' is empty"); -} - -#[test] -fn empty_closes_section() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Closes\n"); - tree.rejects("'## Closes' is empty"); -} - -/// Trailing whitespace is invisible in a diff and `rg '^## Required$'` does not -/// match it either, so accepting it recreates the same disagreement a setext -/// heading does. -#[test] -fn heading_with_trailing_space() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required \n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - tree.rejects("must be written literally as '## Required'"); -} - -/// The index and the cycle walk key on the same normalized path. A `..` left in -/// either makes a node nothing can match, so the cycle through it disappears. -#[test] -fn cycle_through_an_unnormalized_link() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- [Two](/quest/m0/line/../line/two.md) - must finish first\n", - ); - tree.rejects("Required cycle:"); -} - -// Readiness: what `quest ready` reports about the same fixture. A blocker list -// is the machine-readable result, so the cases that must come back EMPTY carry -// as much weight as the ones that must not. - -/// The absence of `## Required` is the whole definition of ready. -#[test] -fn ready_quest_has_no_blockers() { - let tree = Tree::new(); - assert!(tree.blockers("quest/m0/line/one.md").is_empty()); -} - -#[test] -fn blocked_by_a_quest() { - let tree = Tree::new(); - assert_eq!(tree.blockers("quest/m0/line/two.md"), ["quest/m0/line/one.md"]); -} - -/// A plain-text bullet names a condition outside the repository, so nothing in -/// the tree can ever clear it: it is a blocker, printed as written. Wrapped -/// here because that is what the bullets in the tree actually look like. -#[test] -fn blocked_by_plain_text() { - let tree = Tree::new(); - tree.append( - "quest/m0/line/one.md", - "\n## Required\n\n- A `moq-video` release that carries\n the encoder\n", - ); - assert_eq!( - tree.blockers("quest/m0/line/one.md"), - ["A moq-video release that carries the encoder"] - ); -} - -/// A questline blocker clears only when the whole line is complete, so the -/// useful answer is which of its quests are still open - all of them, since a -/// completed quest is deleted. -#[test] -fn blocked_by_a_questline() { - let tree = Tree::new(); - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write( - "quest/m0/outer.md", - "# [S] Outer\n\n## Goal\n\nBlocked on a whole questline.\n\n## Required\n\n- [Line](/quest/m0/line/README.md) - the whole questline must finish\n", - ); - assert_eq!( - tree.blockers("quest/m0/outer.md"), - [ - "quest/m0/line/README.md", - " quest/m0/line/one.md", - " quest/m0/line/two.md", - ] - ); -} - -/// A required QUEST does not expand: its own blockers are its readiness, and -/// running this on it is how you ask. Expanding buried the entries that were -/// asked for under a subtree repeated once per path through it. -#[test] -fn a_required_quest_is_not_expanded() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "- [Three](/quest/m0/line/three.md)\n"); - tree.write( - "quest/m0/line/three.md", - "# [S] Three\n\n## Goal\n\nLast in the chain.\n\n## Required\n\n- [Two](/quest/m0/line/two.md) - must finish first\n", - ); - assert_eq!(tree.blockers("quest/m0/line/three.md"), ["quest/m0/line/two.md"]); -} - -/// `quest check` reports an empty `## Required` as a defect, and every reader -/// that greps for the heading calls the quest blocked. Reading it as ready here -/// would make this the one tool that disagrees. -#[test] -fn empty_required_section_still_blocks() { - let tree = Tree::new(); - tree.append("quest/m0/line/one.md", "\n## Required\n"); - assert_eq!( - tree.blockers("quest/m0/line/one.md"), - ["an empty '## Required' section, which blocks the quest until the heading is removed"] - ); -} - -/// The listing is the query the start flow reproduces by grepping. Questlines -/// are never executed, so they are not in it, and `two.md` is blocked. -#[test] -fn ready_listing() { - let tree = Tree::new(); - assert_eq!(tree.ready(), ["quest/m0/line/one.md"]); - - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write("quest/m0/outer.md", "# [S] Outer\n\n## Goal\n\nReady too.\n"); - assert_eq!(tree.ready(), ["quest/m0/line/one.md", "quest/m0/outer.md"]); -} - -/// `quest check` proves the graph acyclic, but readiness also runs on trees -/// nobody has checked yet - the branch that just introduced the cycle - and a -/// cycle there has to print rather than recurse forever. -#[test] -fn cycle_terminates() { - let tree = Tree::new(); - tree.append("quest/m0/line/README.md", "- [Itself](/quest/m0/line/README.md)\n"); - tree.append("quest/m0/README.md", "- [Outer](/quest/m0/outer.md)\n"); - tree.write( - "quest/m0/outer.md", - "# [S] Outer\n\n## Goal\n\nBlocked on a questline that lists itself.\n\n## Required\n\n- [Line](/quest/m0/line/README.md) - the whole questline must finish\n", - ); - assert_eq!( - tree.blockers("quest/m0/outer.md"), - [ - "quest/m0/line/README.md", - " quest/m0/line/one.md", - " quest/m0/line/two.md", - " quest/m0/line/README.md", - ] - ); -} - -#[test] -fn ready_listing_follows_nested_priority_and_terminates_cycles() { - let tree = Tree::new(); - tree.write("quest/m0/line/two.md", "# [S] Two\n\n## Goal\n\nReady.\n"); - tree.write("quest/m0/line/README.md", "# Line\n\n## Quests\n\n- [Two](/quest/m0/line/two.md)\n- [Self](/quest/m0/line/README.md)\n- [One](/quest/m0/line/one.md)\n"); - assert_eq!(tree.ready(), ["quest/m0/line/two.md", "quest/m0/line/one.md"]); -} - -#[test] -fn ready_listing_appends_unindexed_quests() { - let tree = Tree::new(); - tree.write("quest/m0/aaa.md", "# [S] Unindexed\n\n## Goal\n\nDiscover me.\n"); - tree.write( - "quest/m0/blocked.md", - "# [S] Blocked\n\n## Goal\n\nWait.\n\n## Required\n\n- External condition\n", - ); - assert_eq!(tree.ready(), ["quest/m0/line/one.md", "quest/m0/aaa.md"]); -} - -impl Tree { - fn branch(&self, path: &str) -> Vec { - quest::branch::chain(self.path(), Path::new(path)).expect("branch") - } - - fn branch_err(&self, path: &str) -> String { - quest::branch::chain(self.path(), Path::new(path)) - .expect_err("expected no branch") - .to_string() - } -} - -/// The chain is the path: the leaf, its line's README, then `main`. Nothing -/// consults git. -#[test] -fn branch_chain_of_a_quest() { - let tree = Tree::new(); - assert_eq!( - tree.branch("quest/m0/line/one.md"), - ["quest/m0/line/one", "quest/m0/line/README", "main"] - ); - assert_eq!( - tree.branch("/quest/m0/line/README.md"), - ["quest/m0/line/README", "main"] - ); -} - -/// Neither the root nor a milestone has a branch; their children merge into `main`. -#[test] -fn root_and_milestone_have_no_branch() { - let tree = Tree::new(); - assert!(tree.branch_err("quest/README.md").contains("no branch")); - assert!(tree.branch_err("quest/m0/README.md").contains("no branch")); -} - -/// Every milestone's work branches from `main`, whatever its priority: starting a -/// quest never moves it. -#[test] -fn later_milestone_branches_from_main() { - let tree = Tree::new(); - tree.write( - "quest/m2/README.md", - "# m2\n\n## Goal\n\nLater work.\n\n## Quests\n\n- [Later](/quest/m2/later.md)\n", - ); - tree.write("quest/m2/later.md", "# [S] Later\n\n## Goal\n\nNot started.\n"); - tree.append("quest/README.md", "- [m2](/quest/m2/README.md)\n"); - tree.accepts(); - assert_eq!(tree.branch("quest/m2/later.md"), ["quest/m2/later", "main"]); -} - -/// A milestone with nothing left is not its own work, so it needs no size and -/// never lists as ready. -#[test] -fn milestone_may_be_empty() { - let tree = Tree::new(); - tree.write("quest/m0/README.md", "# m0\n\n## Goal\n\nEmpty for now.\n"); - std::fs::remove_dir_all(tree.path().join("quest/m0/line")).expect("rm"); - tree.accepts(); - assert!(tree.ready().is_empty(), "{:?}", tree.ready()); -} - -/// Lines nest to any depth: every branch ends in a leaf component (the quest -/// or `README`), so no line's branch is a path prefix of its children's, which -/// is the one shape git refuses. -#[test] -fn branch_chain_of_a_nested_line() { - let tree = Tree::new(); - tree.write( - "quest/m0/line/sub/README.md", - "# Sub\n\n## Goal\n\nA nested line.\n\n## Quests\n\n- [Three](/quest/m0/line/sub/three.md)\n", - ); - tree.write( - "quest/m0/line/sub/three.md", - "# [S] Three\n\n## Goal\n\nA nested quest.\n", - ); - tree.append("quest/m0/line/README.md", "- [Sub](/quest/m0/line/sub/README.md)\n"); - tree.accepts(); - assert_eq!( - tree.branch("quest/m0/line/sub/three.md"), - [ - "quest/m0/line/sub/three", - "quest/m0/line/sub/README", - "quest/m0/line/README", - "main" - ] - ); -} diff --git a/test/README.md b/test/README.md index 5c4f994c43..ae54517fa2 100644 --- a/test/README.md +++ b/test/README.md @@ -19,9 +19,11 @@ Every run owns three things and touches nothing else. **A private run directory.** `mktemp -d` under `$TMPDIR/moq-test-`, mode 700, holding every log, generated config, and capture. `MOQ_TEST_RUNS` moves the root. +Client builds go here too (the Python venv, the staged Go modules, the browser +page), so two runs from one checkout never rebuild a client the other is running. **Reserved ports.** A port is claimed by creating a directory under -`$TMPDIR/moq-test-ports-` (`MOQ_TEST_PORTS`), held for the whole run, and +`/tmp/moq-test-ports-` (`MOQ_TEST_PORTS`), held for the whole run, and released on the way out. That reservation is the point: probing for a free port and then releasing it is a race, and two runs that probe at the same moment pick the same number. The walk starts at `MOQ_TEST_PORT_BASE` (4500). A reservation whose owner @@ -30,10 +32,12 @@ Replacement is serialized by `flock` on Linux or `lockf` on macOS, and the reservation records the owner's process start so a reused PID is not mistaken for the original run. -Both roots carry the user id because `TMPDIR` is usually unset on Linux: a fixed -name in a world-writable `/tmp` belongs to whoever ran first, and everyone else -would fail to create anything under it. Two worktrees still share, since they run -as the same user, which is what makes the reservations mean anything. +The reservation root ignores `TMPDIR`: Nix shells have private temporary +directories but share the host's ports. The user id avoids ownership conflicts in +world-writable `/tmp`. An override via `MOQ_TEST_PORTS` must be the same for every +run sharing the network. The root must belong to the current user and cannot be +a symlink; new roots are private. Runs by different users still need disjoint +ports. The reservation settles contention between harness runs, not with the rest of the machine, so each harness still refuses a port something unrelated is already diff --git a/test/drill/README.md b/test/drill/README.md index db075b8753..bf3600f75d 100644 --- a/test/drill/README.md +++ b/test/drill/README.md @@ -112,7 +112,9 @@ protocol's reaction to loss and delay, not the kernel's rendering of them. ## Sensitivity The Nightly workflow runs all mutations, so patches that stop applying and drills -that stop detecting their recovery failures fail CI. +that stop detecting their recovery failures fail CI. `just check` also runs +`--apply-only` whenever Rust or this directory changes, so a patch that no longer +applies fails the PR that moved its code rather than the next nightly. `sensitivity.sh` removes one recovery behavior at a time and requires the drill covering it to fail. Each mutation is a patch under `mutations/`, applied to a diff --git a/test/drill/mutations/relay-withdraws-lost-publisher.patch b/test/drill/mutations/relay-withdraws-lost-publisher.patch index 1f30d190ee..47ff59c45d 100644 --- a/test/drill/mutations/relay-withdraws-lost-publisher.patch +++ b/test/drill/mutations/relay-withdraws-lost-publisher.patch @@ -6,25 +6,25 @@ # means a route outlives the session that announced it, so a crashed publisher's # name stays announced with nothing behind it. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs -index 729a23e4b..dac9990c6 100644 +index 8a0259fec..0b6035640 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs -@@ -2566,7 +2566,8 @@ struct AnnouncedRoute { +@@ -2842,7 +2842,8 @@ struct AnnouncedRoute { /// without recomputing the chain. route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. - dynamic: crate::origin::Dynamic, + // MUTATION: never dropped, so a route outlives the session that announced it. + dynamic: std::mem::ManuallyDrop, - /// 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, -@@ -2578,7 +2579,7 @@ impl AnnouncedRoute { - fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic) -> Self { + /// Whether the GOAWAY drain already re-priced this route. +@@ -2858,7 +2859,7 @@ impl AnnouncedRoute { + fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic, wake: Arc) -> Self { Self { route, - dynamic, + dynamic: std::mem::ManuallyDrop::new(dynamic), sources: HashMap::new(), drained: false, - } + waker: std::task::Waker::from(wake.clone()), diff --git a/test/drill/mutations/subscriber-leaks-broadcasts.patch b/test/drill/mutations/subscriber-leaks-broadcasts.patch index 44bff5a4d2..328b418eb7 100644 --- a/test/drill/mutations/subscriber-leaks-broadcasts.patch +++ b/test/drill/mutations/subscriber-leaks-broadcasts.patch @@ -3,14 +3,14 @@ # # Removes the release a subscribing session performs when it ends: each announce # stream's `Announced` collection owns the routes and source guards for every -# broadcast the session fed, and dropping it aborts them. Wrapping it in -# `ManuallyDrop` keeps every handle downstream of it alive after the session is -# gone, so a cancelled reader's broadcast parks forever instead of closing. +# broadcast the session fed, and dropping it retracts and closes them. Wrapping +# it in `ManuallyDrop` keeps every handle downstream of it alive after the session +# is gone, so a cancelled reader's broadcast parks forever instead of closing. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs -index 729a23e4b..54aa7a5be 100644 +index 8a0259fec..92febbcdf 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs -@@ -1087,7 +1087,8 @@ struct PrefixRun { +@@ -1142,7 +1142,8 @@ struct PrefixRun { /// it comes from the connect config or the peer's SETUP, neither of which /// changes for the life of the session. link_cost: u64, @@ -19,13 +19,11 @@ index 729a23e4b..54aa7a5be 100644 + announced: std::mem::ManuallyDrop, // Lite06+: announce ids. Each received `active` implicitly assigns the next // per-stream ordinal; `ended`/`restart` reference it instead of repeating the - // path. Tracked even for announces we drop locally (reflected loops), since -@@ -1175,7 +1176,7 @@ impl AnnouncePrefix { - let run = PrefixRun { + // path, and lite-07 bases name it too. Tracked even for announces we drop +@@ -1233,5 +1234,5 @@ impl AnnouncePrefix { responder_origin, link_cost, - announced: Announced::default(), + announced: std::mem::ManuallyDrop::new(Announced::default()), - next_announce_id: 0, - announced_by_id: HashMap::new(), + decoder: lite::AnnounceDecoder::default(), }; diff --git a/test/drill/sensitivity.sh b/test/drill/sensitivity.sh index ba4c19d7cf..92ec1b03f9 100755 --- a/test/drill/sensitivity.sh +++ b/test/drill/sensitivity.sh @@ -32,6 +32,7 @@ CARGO=cargo KEEP=0 BASELINE=1 +APPLY_ONLY=0 SELECTED=() dir= log= @@ -68,6 +69,7 @@ Options: --list list the mutations and the drill each one must break --keep keep the mutated snapshots (prints each path) --no-baseline skip the unmutated run of each drill + --apply-only only check that each mutation still applies; builds nothing -h, --help this With no mutation named, every mutation runs. @@ -88,6 +90,10 @@ while [[ $# -gt 0 ]]; do BASELINE=0 shift ;; + --apply-only) + APPLY_ONLY=1 + shift + ;; -h | --help) usage exit 0 @@ -157,6 +163,14 @@ for patch in "$MUTATIONS"/*.patch; do checked=$((checked + 1)) echo "=== $name -> $drill" + if [[ $APPLY_ONLY -eq 1 ]]; then + if ! patch -p1 -d "$WORKSPACE" --dry-run --batch --forward --silent <"$patch"; then + echo " FAIL: '$name' does not apply to this tree" >&2 + failed=$((failed + 1)) + fi + continue + fi + if [[ $BASELINE -eq 1 ]]; then log=$(mktemp "${TMPDIR:-/tmp}/drill-baseline.XXXXXX") status=$(run_drill "$WORKSPACE" "$drill" "$log") @@ -221,6 +235,15 @@ if [[ $checked -eq 0 ]]; then exit 2 fi +if [[ $APPLY_ONLY -eq 1 ]]; then + if [[ $failed -gt 0 ]]; then + echo "$failed of $checked mutations do not apply; retarget them at the current code" >&2 + exit 1 + fi + echo "$checked of $checked mutations apply" + exit 0 +fi + if [[ $failed -gt 0 ]]; then echo "$failed of $checked mutations did not prove sensitivity" >&2 exit 1 diff --git a/test/interop/README.md b/test/interop/README.md index 9f645062b9..ac44b554bb 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -28,7 +28,7 @@ player survive the publication lifecycle. See [Media QA](#media-qa). | Client | Source under test | Built with | Roles | |---|---|---|---| | Rust | `rs/moq-relay` + `rs/moq-cli` | `cargo build` | publish (video) + subscribe | -| Python | `py/moq-rs` (+ `rs/moq-ffi`, import `moq`) | `just py build` (maturin editable into `.venv`) | publish (video + audio) + subscribe | +| Python | `py/moq-rs` (+ `rs/moq-ffi`, import `moq`) | `uv build` wheels (maturin + hatchling), installed into a venv in the run directory | publish (video + audio) + subscribe | | Go | `go/wrapper` (+ `rs/moq-ffi`, import `moq-go/moq`) | `go/scripts/stage.sh` (uniffi-bindgen-go) + `go build` | publish (video + audio) + subscribe | | Browser | `js/watch` + `js/publish` | `vite build` + headless Chromium (Playwright) | publish (video + audio) + rendered playback | | Native JS | `js/net` + `js/hang` + the npm `@moq/web-transport` polyfill | `node` (tsx) and `bun` | subscribe | diff --git a/test/interop/clients/js-native/package.json b/test/interop/clients/js-native/package.json index 3931408011..2639f17155 100644 --- a/test/interop/clients/js-native/package.json +++ b/test/interop/clients/js-native/package.json @@ -11,6 +11,6 @@ "zod": "^4.6.5" }, "devDependencies": { - "tsx": "^4.23.13" + "tsx": "^4.23.15" } } diff --git a/test/interop/clients/js/driver.ts b/test/interop/clients/js/driver.ts index efa0c1ecd4..508cf30f47 100644 --- a/test/interop/clients/js/driver.ts +++ b/test/interop/clients/js/driver.ts @@ -1,5 +1,5 @@ /** - * Drives a headless Chromium against the vite-built page (dist/) for the interop matrix. publish + * Drives a headless Chromium against the vite-built page (see `serve`) for the interop matrix. publish * streams fake camera/microphone input until killed; subscribe verifies rendered playback, * pause/resume, and optionally browser-to-browser audio. * @@ -22,6 +22,7 @@ import { type PlayerState, POLL_INTERVAL_MS, pageUrl, + pause, readPlayerState, SELECTORS, serve, @@ -88,7 +89,12 @@ const browser = await launch([ let code = 1; try { - const [page, errors] = await open(browser, pageUrl(server.origin, role, { url, broadcast }), role, role === "subscribe"); + const [page, errors] = await open( + browser, + pageUrl(server.origin, role, { url, broadcast }), + role, + role === "subscribe", + ); if (role === "subscribe") await waitForWatch(page); if (role === "publish") { @@ -129,10 +135,7 @@ try { }); } - // The chrome auto-hides while playing. Pointer activity reveals the real - // control, then the click must flow through the public player API. - await page.dispatchEvent(SELECTORS.ui, "pointermove"); - await page.locator(SELECTORS.ui).locator(SELECTORS.pauseControl).click(); + await pause(page); await waitForState(page, errors, { deadline: interactionDeadline, description: "paused player UI", diff --git a/test/interop/clients/js/harness.browser.ts b/test/interop/clients/js/harness.browser.ts new file mode 100644 index 0000000000..ec07edd378 --- /dev/null +++ b/test/interop/clients/js/harness.browser.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { launch, pause } from "./harness"; + +test("pause activates its button when a canvas intercepts pointer input", async () => { + const browser = await launch(); + try { + const page = await browser.newPage(); + await page.setContent(` + + + `); + await pause(page); + expect(await page.locator("body").getAttribute("data-paused")).toBe("true"); + } finally { + await browser.close(); + } +}); diff --git a/test/interop/clients/js/harness.ts b/test/interop/clients/js/harness.ts index 019d4a003f..bd59af1602 100644 --- a/test/interop/clients/js/harness.ts +++ b/test/interop/clients/js/harness.ts @@ -63,15 +63,27 @@ export const SELECTORS = { fixture: "#fixture", } as const; +/** Activate the player's pause button without depending on pointer hit testing. */ +export async function pause(page: Page): Promise { + // Enter focuses and activates the real button even when the chrome auto-hides. + await page.locator(SELECTORS.ui).locator(SELECTORS.pauseControl).press("Enter"); +} + /** How often a wait re-reads the page. */ export const POLL_INTERVAL_MS = 100; /** Sleep for `ms`. */ export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -/** Serve the prebuilt page on localhost, a secure context so WebTransport and WebCodecs are enabled. */ +/** + * Serve the prebuilt page on localhost, a secure context so WebTransport and WebCodecs are enabled. + * + * `interop.sh` builds the page into its run directory, so a concurrent run's rebuild cannot empty + * it mid-load; outside a harness run, it is vite's default `dist/`. + */ export function serve(): { origin: string; stop: () => void } { - const root = join(new URL(".", import.meta.url).pathname, "dist"); + const run = process.env.MOQ_TEST_RUN; + const root = run ? join(run, "js-dist") : join(new URL(".", import.meta.url).pathname, "dist"); const server = Bun.serve({ port: 0, async fetch(req) { diff --git a/test/interop/clients/js/media.ts b/test/interop/clients/js/media.ts index 6ac033f31b..787b00665e 100644 --- a/test/interop/clients/js/media.ts +++ b/test/interop/clients/js/media.ts @@ -30,6 +30,7 @@ import { type PlayerState, POLL_INTERVAL_MS, pageUrl, + pause, readFixtureState, readPlayerState, SELECTORS, @@ -526,9 +527,7 @@ try { // ── pause and resume ───────────────────────────────────────────────────── if (wants("pause")) { console.error("=== pause and resume ==="); - // The chrome auto-hides while playing; pointer activity reveals the real control. - await player.dispatchEvent(SELECTORS.ui, "pointermove"); - await player.locator(SELECTORS.ui).locator(SELECTORS.pauseControl).click(); + await pause(player); await waitForState(player, playerErrors, { deadline: Date.now() + SETTLE_MS, assertion: "pause takes effect", diff --git a/test/interop/clients/js/src/contract.ts b/test/interop/clients/js/src/contract.ts index d33d91d49a..d7fc63342c 100644 --- a/test/interop/clients/js/src/contract.ts +++ b/test/interop/clients/js/src/contract.ts @@ -165,7 +165,7 @@ export type InteropControl = { start(): void; /** Remove the player from the DOM. */ detach(): void; - /** Put the player back and resume sampling. */ + /** Blank the canvas the old session left behind, put the player back, and resume sampling. */ reattach(): void; /** Connect a second player and leave it behind for the leaked-session negative control. */ startLeak(): void; diff --git a/test/interop/clients/js/src/setup.ts b/test/interop/clients/js/src/setup.ts index 1ebd379e00..aeb4177abb 100644 --- a/test/interop/clients/js/src/setup.ts +++ b/test/interop/clients/js/src/setup.ts @@ -110,6 +110,11 @@ if (role === "publish") { }, reattach: () => { stop(); + // The torn-down player leaves its last frame on the canvas, which can be newer than the frame + // the driver read before detaching. Blank it so every frame read after this was presented by + // the new session, not left over from the old one. + const canvas = el.querySelector("canvas"); + canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); player.appendChild(el); stop = attach(el); }, diff --git a/test/interop/clients/js/tsconfig.json b/test/interop/clients/js/tsconfig.json index a02418e962..af3d0a2b27 100644 --- a/test/interop/clients/js/tsconfig.json +++ b/test/interop/clients/js/tsconfig.json @@ -3,5 +3,13 @@ "compilerOptions": { "types": ["bun", "vite/client"] }, - "include": ["driver.ts", "harness.ts", "media.ts", "src", "vite.config.ts", "../../../../js/common/worklet.d.ts"] + "include": [ + "driver.ts", + "harness.ts", + "harness.browser.ts", + "media.ts", + "src", + "vite.config.ts", + "../../../../js/common/worklet.d.ts" + ] } diff --git a/test/interop/interop.sh b/test/interop/interop.sh index f098b42a56..180985b775 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -188,23 +188,31 @@ build_relay_cli() { [[ -n "$MOQ" ]] || MOQ="$TARGET_BASE/$PROFILE/moq" } -# Editable-install the workspace Python build (maturin builds rs/moq-ffi, then -# the moq-rs wrapper installs on top) into the repo-root .venv. `import moq` -# then resolves to this checkout, not a PyPI wheel. +# Build the workspace Python packages as wheels (maturin builds rs/moq-ffi, hatchling +# the moq-rs wrapper) and install them into a venv in the run directory, so `import moq` +# resolves to this checkout rather than a PyPI wheel. The shared .venv is off limits: a +# concurrent run's `just py build` uninstalls the package there while this run's +# clients are importing it. prepare_python() { have uv || { mark_broken python "uv not found" return } - echo "building python client (workspace moq via maturin)..." - if (cd "$WORKSPACE" && just py build) >"$HARNESS_RUN/py-build.log" 2>&1; then - PY="$WORKSPACE/.venv/bin/python" - [[ -x "$PY" ]] || { - mark_broken python "workspace .venv python not found after build" - sed 's/^/ /' "$HARNESS_RUN/py-build.log" >&2 || true - } + echo "building python client (workspace moq wheels)..." + local venv="$HARNESS_RUN/py-venv" wheels="$HARNESS_RUN/py-wheels" + # maturin stages the bindings at a fixed path under the cargo target dir, so one + # build at a time per target. Debug like the other clients; its build backend + # defaults to release. + if (cd "$WORKSPACE" && + harness_locked "$TARGET_BASE/.moq-test-maturin.lock" \ + env MATURIN_PEP517_ARGS="--profile dev --locked" \ + uv build --wheel --package moq-ffi --out-dir "$wheels" && + uv build --wheel --package moq-rs --out-dir "$wheels" && + uv venv "$venv" && + uv pip install --python "$venv/bin/python" --no-deps "$wheels"/*.whl) >"$HARNESS_RUN/py-build.log" 2>&1; then + PY="$venv/bin/python" else - mark_broken python "just py build failed" + mark_broken python "wheel build failed" sed 's/^/ /' "$HARNESS_RUN/py-build.log" >&2 || true fi } @@ -230,7 +238,9 @@ prepare_js() { elif ! (cd "$CLIENTS/js" && bun run check) >"$HARNESS_RUN/js-check.log" 2>&1; then mark_broken js "type check failed" sed 's/^/ /' "$HARNESS_RUN/js-check.log" >&2 || true - elif ! (cd "$CLIENTS/js" && bunx vite build) >"$HARNESS_RUN/js-vite.log" 2>&1; then + # Into the run directory, where harness.ts serves it from: vite empties its output + # first, so a shared dist/ vanishes under a concurrent run's page loads. + elif ! (cd "$CLIENTS/js" && bunx vite build --outDir "$HARNESS_RUN/js-dist" --emptyOutDir) >"$HARNESS_RUN/js-vite.log" 2>&1; then mark_broken js "vite build failed" sed 's/^/ /' "$HARNESS_RUN/js-vite.log" >&2 || true fi @@ -256,7 +266,7 @@ prepare_go() { } echo "building go client (workspace moq-go via uniffi-bindgen-go)..." local staged ffi_pkg wrapper_pkg src="$HARNESS_RUN/go-client" - if ! staged=$(bash "$WORKSPACE/go/scripts/stage.sh" 2>"$HARNESS_RUN/go-stage.log"); then + if ! staged=$(bash "$WORKSPACE/go/scripts/stage.sh" --output "$HARNESS_RUN/go-stage" 2>"$HARNESS_RUN/go-stage.log"); then mark_broken go "go/scripts/stage.sh failed" sed 's/^/ /' "$HARNESS_RUN/go-stage.log" >&2 || true return @@ -306,7 +316,7 @@ prepare_c() { return } # cargo can't inject libmoq.a's native deps into an external link, so read - # them from the same list build.rs and CMake use. + # them from the same list moq-c.pc and CMake use. local native_libs case "$(uname -s)" in Darwin) native_libs="$WORKSPACE/rs/moq-c/native-libs/apple.txt" ;; diff --git a/test/justfile b/test/justfile index 080e684192..fdd052c4b0 100644 --- a/test/justfile +++ b/test/justfile @@ -22,6 +22,15 @@ set working-directory := '.' default: @just --justfile {{ justfile() }} --list test +# Port isolation across private temp roots and keyboard activation under a canvas. +# The browser check is not `*.test.ts`: plain `bun test` runs lack Playwright Chromium, +# which this recipe installs, as the interop and wasm harnesses do. +harness: + ./lib/harness.test.sh + cd .. && bun install --frozen-lockfile + cd interop/clients/js && bunx playwright install chromium + cd interop/clients/js && bun test ./harness.browser.ts + # Cross-language media interop test, built from this checkout. Stands up a # relay and runs the publisher x subscriber matrix. Default: rust only (fast # sanity check). `interop --all` runs the full matrix: rust + python + go + diff --git a/test/lib/harness.sh b/test/lib/harness.sh index 7180d384c5..de6c3e729a 100644 --- a/test/lib/harness.sh +++ b/test/lib/harness.sh @@ -105,13 +105,11 @@ harness_begin() { # Where port reservations live. Shared across worktrees on purpose: the point is # that a run in one worktree cannot hand out a port another already took. # -# The default is suffixed with the user id, like the run root. On Linux TMPDIR is -# usually unset, so both would land in a world-writable /tmp under a fixed name -# owned by whoever ran first: a second user's `mkdir` would then fail for every -# port and the walk would report the whole range taken with nothing reserved. Two -# worktrees still share, because they run as the same user. +# Nix shells give TMPDIR a private directory, but their sockets still share the +# host network. Keep claims in /tmp regardless of each shell's scratch root. +# The user id avoids ownership conflicts with another user's harnesses. harness_port_root() { - local root="${MOQ_TEST_PORTS:-${TMPDIR:-/tmp}}" + local root="${MOQ_TEST_PORTS:-/tmp}" root="${root%/}" [[ -n "${MOQ_TEST_PORTS:-}" ]] || root="$root/moq-test-ports-$(id -u)" echo "$root" @@ -146,7 +144,13 @@ harness_port() { local label="$1" wanted="${2:-}" local root port last status root=$(harness_port_root) - mkdir -p "$root" + # Only the reservation root needs private permissions, not its parents. + # shellcheck disable=SC2174 + mkdir -m 700 -p "$root" + if [[ -L "$root" || ! -O "$root" ]]; then + echo "error: port reservation root must be owned by this user and not a symlink: $root" >&2 + return 2 + fi if [[ -n "$wanted" ]]; then harness_valid_port "$wanted" || { @@ -191,16 +195,25 @@ harness_port() { # Claim one port. Private; `harness_port` is the entry point. harness_port_take() { - local root="$1" port="$2" lock="$1/.lock-$2" + local root="$1" port="$2" + harness_locked "$root/.lock-$port" "$HARNESS_LIB/reserve.sh" "$root" "$port" "$$" "$HARNESS_RUN" || return $? + HARNESS_PORTS+=("$root/$port") +} + +# Run CMD holding an exclusive advisory lock on FILE: `harness_locked `. +# CMD is an executable, not a shell function. For a step that writes somewhere every +# run shares, so concurrent runs take turns instead of clobbering each other. +harness_locked() { + local lock="$1" + shift if command -v flock >/dev/null 2>&1; then - flock "$lock" "$HARNESS_LIB/reserve.sh" "$root" "$port" "$$" "$HARNESS_RUN" || return $? + flock "$lock" "$@" elif command -v lockf >/dev/null 2>&1; then - lockf -k "$lock" "$HARNESS_LIB/reserve.sh" "$root" "$port" "$$" "$HARNESS_RUN" || return $? + lockf -k "$lock" "$@" else - echo "error: port reservations require flock or lockf" >&2 + echo "error: the test harness requires flock or lockf" >&2 return 2 fi - HARNESS_PORTS+=("$root/$port") } # Record an endpoint this run stood up: `harness_endpoint