From 68690195751da601be64285763ccdccf2bd7e5b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 10:34:39 -0700 Subject: [PATCH 1/7] quest: open the wildcard line Co-Authored-By: Claude Opus 5.5 From 6928edc8d9f68cbb97f860fa2977d6e44368d4b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 06:33:19 -0700 Subject: [PATCH 2/7] feat(net): re-resolve once on a NO_CAPACITY refusal (#4050) Co-authored-by: Claude Opus 5.5 Co-authored-by: Franz Heinzmann Co-authored-by: Grok Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- .config/nextest.toml | 5 + .github/workflows/cachix.yml | 6 +- .github/workflows/check.yml | 3 - .github/workflows/docker.yml | 45 +- .github/workflows/obs.yml | 5 +- .github/workflows/release-winget.yml | 2 +- .github/workflows/swift.yml | 4 + Cargo.lock | 94 +- Cargo.toml | 44 +- Dockerfile | 5 +- dart/moq/README.md | 1 + dart/moq/lib/src/client.dart | 27 +- dart/moq/pubspec.yaml | 2 +- dart/moq/test/moq_test.dart | 52 +- dart/moq_ffi/lib/src/moq.dart | 67 +- doc/.vitepress/config.ts | 1 + doc/bin/cli.md | 25 +- doc/bin/hls.md | 12 +- doc/bin/relay/auth.md | 13 +- doc/bin/relay/cluster.md | 7 + doc/concept/moq-lite.md | 9 + doc/lib/c/index.md | 6 +- doc/lib/dart/index.md | 7 +- doc/lib/go/index.md | 6 +- doc/lib/index.md | 2 +- doc/lib/js/net.md | 10 +- doc/lib/js/publish.md | 2 +- doc/lib/kt/index.md | 14 +- doc/lib/py/index.md | 20 +- doc/lib/rs/index.md | 4 + doc/lib/rs/moq-auth.md | 2 +- doc/lib/rs/moq-net.md | 16 +- doc/lib/rs/moq-video.md | 2 +- doc/lib/samples.sh | 54 + doc/lib/swift/index.md | 19 +- doc/setup/install.md | 14 +- doc/setup/upgrade.md | 192 ++++ drafts/draft-lcurley-moq-cluster.md | 43 +- drafts/draft-lcurley-moq-hang.md | 8 +- drafts/draft-lcurley-moq-lite.md | 11 +- drafts/draft-lcurley-moq-pattern.md | 98 -- drafts/justfile | 5 + flake.nix | 14 +- go/wrapper/client.go | 29 +- go/wrapper/moq_test.go | 33 +- go/wrapper/origin.go | 15 +- go/wrapper/publish.go | 7 +- go/wrapper/reconnect_test.go | 26 + go/wrapper/server.go | 2 +- js/auth/src/contract.ts | 2 + js/auth/src/interop.test.ts | 3 +- js/net/src/broadcast.ts | 11 +- js/net/src/ietf/publisher.ts | 13 +- js/net/src/integration.test.ts | 27 + js/net/src/lite/publisher.ts | 21 +- js/net/src/origin.test.ts | 147 ++- js/net/src/origin.ts | 213 ++-- js/net/src/wire.ts | 2 + js/publish/src/audio/encoder.test.ts | 201 +++- js/publish/src/audio/encoder.ts | 26 +- js/publish/src/audio/framer.ts | 5 + js/publish/src/broadcast.ts | 5 +- js/publish/src/element.ts | 2 +- justfile | 5 +- kt/.gitignore | 1 + kt/README.md | 6 +- kt/justfile | 2 +- kt/moq/build.gradle.kts | 7 +- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 16 +- .../kotlin/dev/moq/SmokeTest.kt | 49 +- .../kotlin/dev/moq/docs/Prelude.kt | 9 + kt/scripts/check.sh | 8 + py/justfile | 26 +- py/moq-rs/README.md | 14 +- py/moq-rs/moq/client.py | 24 +- py/moq-rs/moq/origin.py | 19 +- py/moq-rs/moq/publish.py | 6 +- py/moq-rs/moq/server.py | 1 + py/moq-rs/pyproject.toml | 6 +- py/moq-rs/tests/doc_prelude.py | 10 + py/moq-rs/tests/test_local.py | 29 +- py/moq-rs/tests/test_server.py | 31 + quest/m0/README.md | 19 +- quest/m0/js-retention.md | 70 ++ quest/m0/plan-av-clock.md | 1 + quest/m0/release.md | 2 +- ...c-abi-catch-up-with-the-moq-ffi-surface.md | 33 - ...apability-gaps-vs-rs-moq-net-setup-role.md | 34 - ...s-dropping-one-split-server-resizes-the.md | 2 +- quest/m1/README.md | 28 +- quest/m1/announce-live-bindings.md | 33 + quest/m1/announce-to-serve.md | 83 -- quest/m1/audio-quality-harness/README.md | 11 +- quest/m1/audio-quality-harness/browser.md | 14 +- quest/m1/auth-embedder.md | 5 +- quest/m1/auth/README.md | 4 + quest/m1/auth/bindings.md | 3 + quest/m1/auth/interop.md | 24 + quest/m1/auth/relay-refresh.md | 12 +- quest/m1/auth/unauthorized.md | 25 + quest/m1/binding-docs.md | 30 - quest/m1/broadcast-close/README.md | 1 - quest/m1/broadcast-epoch/origin.md | 1 - quest/m1/cli-inspect/README.md | 1 - quest/m1/cli-inspect/fetch.md | 31 - quest/m1/cpp/README.md | 1 - quest/m1/data-jitter.md | 48 + quest/m1/data-sections.md | 66 ++ quest/m1/effect-cancel.md | 17 + quest/m1/ffi-shape/README.md | 1 - quest/m1/ffi-websocket-fallback.md | 52 - quest/m1/gateway-embed.md | 10 +- quest/m1/gpu-pool-reservation.md | 4 +- quest/m1/hls-generation.md | 50 - quest/m1/ietf-publish-done.md | 21 + quest/m1/jitter-flush-clock.md | 114 ++- quest/m1/js-announce-caught-up.md | 4 +- quest/m1/js-broadcast-route.md | 22 - quest/m1/js-publish-discontinuity.md | 16 - quest/m1/js-track-tail.md | 111 +++ quest/m1/ladder/README.md | 2 +- quest/m1/libmoq-fetch.md | 4 - quest/m1/listener-close.md | 20 + quest/m1/net-ingest-source.md | 41 - quest/m1/perf/README.md | 1 - quest/m1/perf/json-decode.md | 18 - quest/m1/plan-watch-worker.md | 70 ++ quest/m1/publisher-clock.md | 11 +- quest/m1/quic/reliable-reset.md | 9 + quest/m1/relay-peer-set.md | 35 + quest/m1/rust-track-tail.md | 54 + quest/m1/session-death-error.md | 60 ++ quest/m1/stats-retier.md | 25 - quest/m1/tokio-test-ports.md | 17 + quest/m1/transport-impairment-profile.md | 45 - quest/m1/uring-handshake-flush.md | 34 - quest/m1/uring-link-facts.md | 22 - quest/m1/watch-worker.md | 39 + quest/m1/wildcard/README.md | 31 +- quest/m1/wildcard/demand.md | 9 +- quest/m1/wildcard/resolve.md | 105 -- quest/m1/wildcard/spread.md | 43 + quest/m2/README.md | 2 + quest/m2/flate/README.md | 4 - quest/m2/js-discontinuity.md | 24 + quest/m2/teleop/robot.md | 12 +- quest/m2/watch-data-sync.md | 29 + quest/m4/video-vaapi.md | 20 +- rs/hang/CHANGELOG.md | 6 + rs/hang/Cargo.toml | 2 +- rs/justfile | 60 +- rs/libmoq/CHANGELOG.md | 6 + rs/libmoq/Cargo.toml | 2 +- rs/libmoq/README.md | 11 + rs/libmoq/c-tests/doc-samples.h | 24 + rs/libmoq/src/api.rs | 277 +++++- rs/libmoq/src/lib.rs | 4 +- rs/libmoq/src/origin.rs | 8 +- rs/libmoq/src/publish.rs | 2 +- rs/libmoq/src/server.rs | 148 +++ rs/libmoq/src/session.rs | 149 ++- rs/libmoq/src/state.rs | 4 +- rs/libmoq/src/test.rs | 233 ++++- rs/moq-archive/CHANGELOG.md | 6 + rs/moq-archive/Cargo.toml | 2 +- rs/moq-archive/README.md | 28 + rs/moq-audio/CHANGELOG.md | 6 + rs/moq-audio/Cargo.toml | 2 +- rs/moq-auth/src/grant.rs | 10 +- rs/moq-binary/CHANGELOG.md | 6 + rs/moq-binary/Cargo.toml | 2 +- rs/moq-binary/README.md | 24 + rs/moq-boy/CHANGELOG.md | 6 + rs/moq-boy/Cargo.toml | 2 +- rs/moq-cli/CHANGELOG.md | 10 + rs/moq-cli/Cargo.toml | 5 +- rs/moq-cli/README.md | 4 +- rs/moq-cli/src/args.rs | 72 +- rs/moq-cli/src/fetch.rs | 360 +++++++ rs/moq-cli/src/main.rs | 10 +- rs/moq-cli/src/publish.rs | 5 +- rs/moq-e2ee/CHANGELOG.md | 6 + rs/moq-e2ee/Cargo.toml | 2 +- rs/moq-ffi/CHANGELOG.md | 6 + rs/moq-ffi/Cargo.toml | 2 +- rs/moq-ffi/src/origin.rs | 29 +- rs/moq-ffi/src/producer.rs | 11 +- rs/moq-ffi/src/session.rs | 41 + rs/moq-ffi/src/test.rs | 115 ++- rs/moq-gst/CHANGELOG.md | 6 + rs/moq-gst/Cargo.toml | 2 +- rs/moq-hls/CHANGELOG.md | 6 + rs/moq-hls/Cargo.toml | 3 +- rs/moq-hls/src/error.rs | 4 + rs/moq-hls/src/export/mod.rs | 257 ++++- rs/moq-hls/src/export/mpd.rs | 39 +- rs/moq-hls/src/export/playlist.rs | 49 +- rs/moq-hls/src/export/rendition.rs | 163 ++- rs/moq-hls/src/export/renditions.rs | 60 +- rs/moq-hls/src/export/segments.rs | 223 +++-- rs/moq-hls/src/export/upstream.rs | 4 +- rs/moq-hls/src/server/mod.rs | 8 +- rs/moq-hls/src/server/routes.rs | 121 ++- rs/moq-json/CHANGELOG.md | 6 + rs/moq-json/Cargo.toml | 6 +- rs/moq-json/benches/window.rs | 141 +++ rs/moq-json/src/snapshot/decoder.rs | 6 + rs/moq-json/src/window/decoder.rs | 17 +- rs/moq-json/src/window/mod.rs | 12 + rs/moq-json/tests/decode_allocations.rs | 91 ++ rs/moq-json/tests/window_allocations.rs | 129 +++ rs/moq-loc/CHANGELOG.md | 6 + rs/moq-loc/Cargo.toml | 2 +- rs/moq-mux/CHANGELOG.md | 6 + rs/moq-mux/Cargo.toml | 2 +- rs/moq-mux/src/catalog/hang/consumer.rs | 10 +- rs/moq-mux/src/source.rs | 6 +- rs/moq-net/CHANGELOG.md | 11 + rs/moq-net/Cargo.toml | 2 +- rs/moq-net/benches/origin.rs | 6 +- rs/moq-net/src/error.rs | 21 +- rs/moq-net/src/ietf/publisher.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 145 ++- rs/moq-net/src/lite/subscriber.rs | 17 +- rs/moq-net/src/model/broadcast.rs | 21 +- rs/moq-net/src/model/front.rs | 446 ++++++++- rs/moq-net/src/model/mod.rs | 4 +- rs/moq-net/src/model/origin.rs | 941 +++++++++++++++--- rs/moq-net/src/stats.rs | 319 ++++-- rs/moq-net/tests/announce_to_serve.rs | 233 +++++ rs/moq-net/tests/loom.rs | 56 +- rs/moq-nvenc/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 6 + rs/moq-relay/Cargo.toml | 4 +- rs/moq-relay/src/auth.rs | 43 +- rs/moq-relay/src/cluster.rs | 59 +- rs/moq-relay/src/connection.rs | 1 + rs/moq-relay/src/fetch.rs | 21 + rs/moq-relay/src/lib.rs | 2 + rs/moq-relay/src/uring.rs | 21 +- rs/moq-relay/src/web.rs | 30 +- rs/moq-relay/src/websocket.rs | 1 + rs/moq-relay/tests/auth_lifetime.rs | 144 ++- rs/moq-relay/tests/drills.rs | 148 ++- rs/moq-relay/tests/runtime_uring.rs | 48 + rs/moq-room/CHANGELOG.md | 6 + rs/moq-room/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 6 + rs/moq-rtc/Cargo.toml | 2 +- rs/moq-rtmp/CHANGELOG.md | 6 + rs/moq-rtmp/Cargo.toml | 2 +- rs/moq-shaper/Cargo.toml | 24 + rs/moq-shaper/src/lib.rs | 727 ++++++++++++++ rs/moq-shaper/src/main.rs | 98 ++ rs/moq-sock/Cargo.toml | 2 +- rs/moq-srt/CHANGELOG.md | 6 + rs/moq-srt/Cargo.toml | 2 +- rs/moq-stats/CHANGELOG.md | 6 + rs/moq-stats/Cargo.toml | 6 +- rs/moq-stats/benches/decode.rs | 146 +++ rs/moq-tokio/CHANGELOG.md | 6 + rs/moq-tokio/Cargo.toml | 2 +- rs/moq-tokio/tests/broadcast.rs | 20 +- rs/moq-tokio/tests/capacity.rs | 181 ++++ rs/moq-transcode/CHANGELOG.md | 6 + rs/moq-transcode/Cargo.toml | 2 +- rs/moq-uring/CHANGELOG.md | 6 + rs/moq-uring/Cargo.toml | 2 +- rs/moq-uring/src/quic/noq/connection.rs | 43 +- rs/moq-uring/tests/endpoint.rs | 12 + rs/moq-v4l/Cargo.toml | 2 +- rs/moq-video/CHANGELOG.md | 6 + rs/moq-video/Cargo.toml | 6 +- rs/moq-video/DESIGN-native-codecs.md | 43 +- rs/moq-video/src/decode/backend/vaapi.rs | 145 +-- rs/moq-video/src/encode/backend/mod.rs | 2 +- rs/moq-video/src/encode/backend/vaapi.rs | 513 +++++++++- rs/moq-video/src/encode/encoder.rs | 13 +- rs/moq-video/src/frame.rs | 17 + rs/moq-video/src/frame/vaapi.rs | 463 +++++++++ rs/moq-video/src/render/renderer.rs | 124 +++ swift/.gitignore | 1 + swift/README.md | 9 +- swift/Sources/Moq/Broadcast.swift | 6 +- swift/Sources/Moq/Client.swift | 12 + swift/Sources/Moq/Origin.swift | 14 +- swift/Tests/MoqTests/Docs/Prelude.swift | 11 + swift/Tests/MoqTests/SmokeTests.swift | 35 +- swift/justfile | 2 +- swift/scripts/check.sh | 5 + test/drill/README.md | 68 +- .../reconnect-stops-after-session-loss.patch | 2 +- .../relay-withdraws-lost-publisher.patch | 2 +- .../subscriber-leaks-broadcasts.patch | 2 +- 294 files changed, 10183 insertions(+), 2315 deletions(-) create mode 100755 doc/lib/samples.sh create mode 100644 doc/setup/upgrade.md delete mode 100644 drafts/draft-lcurley-moq-pattern.md create mode 100644 kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/docs/Prelude.kt create mode 100644 py/moq-rs/tests/doc_prelude.py create mode 100644 quest/m0/js-retention.md delete mode 100644 quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md delete mode 100644 quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md create mode 100644 quest/m1/announce-live-bindings.md delete mode 100644 quest/m1/announce-to-serve.md create mode 100644 quest/m1/auth/interop.md create mode 100644 quest/m1/auth/unauthorized.md delete mode 100644 quest/m1/binding-docs.md delete mode 100644 quest/m1/cli-inspect/fetch.md create mode 100644 quest/m1/data-jitter.md create mode 100644 quest/m1/data-sections.md create mode 100644 quest/m1/effect-cancel.md delete mode 100644 quest/m1/ffi-websocket-fallback.md delete mode 100644 quest/m1/hls-generation.md create mode 100644 quest/m1/ietf-publish-done.md delete mode 100644 quest/m1/js-broadcast-route.md delete mode 100644 quest/m1/js-publish-discontinuity.md create mode 100644 quest/m1/js-track-tail.md create mode 100644 quest/m1/listener-close.md delete mode 100644 quest/m1/net-ingest-source.md delete mode 100644 quest/m1/perf/json-decode.md create mode 100644 quest/m1/plan-watch-worker.md create mode 100644 quest/m1/relay-peer-set.md create mode 100644 quest/m1/rust-track-tail.md create mode 100644 quest/m1/session-death-error.md delete mode 100644 quest/m1/stats-retier.md create mode 100644 quest/m1/tokio-test-ports.md delete mode 100644 quest/m1/transport-impairment-profile.md delete mode 100644 quest/m1/uring-handshake-flush.md delete mode 100644 quest/m1/uring-link-facts.md create mode 100644 quest/m1/watch-worker.md delete mode 100644 quest/m1/wildcard/resolve.md create mode 100644 quest/m1/wildcard/spread.md create mode 100644 quest/m2/js-discontinuity.md create mode 100644 quest/m2/watch-data-sync.md create mode 100644 rs/libmoq/c-tests/doc-samples.h create mode 100644 rs/libmoq/src/server.rs create mode 100644 rs/moq-archive/README.md create mode 100644 rs/moq-binary/README.md create mode 100644 rs/moq-cli/src/fetch.rs create mode 100644 rs/moq-json/benches/window.rs create mode 100644 rs/moq-json/tests/decode_allocations.rs create mode 100644 rs/moq-json/tests/window_allocations.rs create mode 100644 rs/moq-net/tests/announce_to_serve.rs create mode 100644 rs/moq-relay/src/fetch.rs create mode 100644 rs/moq-shaper/Cargo.toml create mode 100644 rs/moq-shaper/src/lib.rs create mode 100644 rs/moq-shaper/src/main.rs create mode 100644 rs/moq-stats/benches/decode.rs create mode 100644 rs/moq-tokio/tests/capacity.rs create mode 100644 rs/moq-video/src/frame/vaapi.rs create mode 100644 swift/Tests/MoqTests/Docs/Prelude.swift diff --git a/.config/nextest.toml b/.config/nextest.toml index c2abb88767..65691871bd 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -19,6 +19,11 @@ slow-timeout = { period = "30s", terminate-after = 2 } # race until proven otherwise (see Root Cause First in CLAUDE.md). retries = 0 +# `--all-targets` still compiles the criterion benches, which is what catches a +# bench that no longer builds. Running them in test mode only replays the +# workload once, and the large origin fan-outs take ~20s each for no signal. +default-filter = "not kind(bench)" + # CI has noisier neighbours and cold caches, so give a test longer before # calling it wedged. [profile.ci] diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml index 34f7198821..7b54d5f2e7 100644 --- a/.github/workflows/cachix.yml +++ b/.github/workflows/cachix.yml @@ -11,7 +11,7 @@ on: jobs: # Build and push only the package the tag names. The tag prefix - # (e.g. moq-relay) maps 1:1 to a flake attribute. + # (e.g. moq-relay) is the flake attribute, except moq-cli ships as moq. release: name: Release (${{ matrix.os }}) runs-on: ${{ matrix.runs-on }} @@ -48,7 +48,9 @@ jobs: echo "Tag format not recognized: $REF_NAME" >&2 exit 1 fi - echo "name=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + name="${BASH_REMATCH[1]}" + [[ "$name" == moq-cli ]] && name=moq + echo "name=${name}" >> "$GITHUB_OUTPUT" - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 80b14d0090..a1b03fd00c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -81,9 +81,6 @@ jobs: env: MOQ_STRICT: 1 - - name: Media feature contracts - run: nix develop --command just rs media-features - test: name: Test if: github.event.action != 'closed' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2a3c3df447..fc6748864b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,13 +11,13 @@ on: workflow_call: inputs: package: - description: "Flake package to build (moq-relay, moq-cli)" + description: "Crate to build (moq-relay, moq-cli)" required: true type: string workflow_dispatch: inputs: package: - description: "Flake package to build (moq-relay, moq-cli)" + description: "Crate to build (moq-relay, moq-cli)" required: true type: string default: moq-relay @@ -32,7 +32,7 @@ jobs: permissions: contents: read outputs: - target: ${{ steps.parse.outputs.target }} + package: ${{ steps.parse.outputs.package }} version: ${{ steps.parse.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -41,19 +41,24 @@ jobs: - id: parse env: - PACKAGE: ${{ inputs.package }} + CRATE: ${{ inputs.package }} run: | ref=${GITHUB_REF#refs/tags/} - if [[ -n "$PACKAGE" ]]; then - target="$PACKAGE" + if [[ -n "$CRATE" ]]; then + crate="$CRATE" elif [[ "$ref" =~ ^([a-z-]+)-v[0-9.]+$ ]]; then - target="${BASH_REMATCH[1]}" + crate="${BASH_REMATCH[1]}" else echo "Tag format not recognized." >&2 exit 1 fi - echo "target=${target}" >> "$GITHUB_OUTPUT" - .github/scripts/release.sh parse-version "$target" + # The flake package and image are named after the executable. + case "$crate" in + moq-cli) package=moq ;; + *) package="$crate" ;; + esac + echo "package=${package}" >> "$GITHUB_OUTPUT" + .github/scripts/release.sh parse-version "$crate" build: name: Build ${{ matrix.platform }} @@ -89,8 +94,8 @@ jobs: context: . platforms: ${{ matrix.platform }} build-args: | - package=${{ needs.parse.outputs.target }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.parse.outputs.target }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' }} + package=${{ needs.parse.outputs.package }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.parse.outputs.package }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' }} - name: Export digest if: github.ref_type == 'tag' @@ -132,6 +137,18 @@ jobs: run: | # shellcheck disable=SC2046 # intentional word-splitting: one arg per digest docker buildx imagetools create \ - -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}:${{ needs.parse.outputs.version }} \ - -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}:latest \ - $(printf "${{ env.REGISTRY }}/${{ needs.parse.outputs.target }}@sha256:%s " *) + -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.package }}:${{ needs.parse.outputs.version }} \ + -t ${{ env.REGISTRY }}/${{ needs.parse.outputs.package }}:latest \ + $(printf "${{ env.REGISTRY }}/${{ needs.parse.outputs.package }}@sha256:%s " *) + + # The image was moqdev/moq-cli through 0.12.2. Point `latest` there at a + # refusal naming the new image, so a user tracking it breaks instead of + # silently staying on 0.12.2. Pinned versions keep working. + - name: Refuse the old image name + if: needs.parse.outputs.package == 'moq' + run: | + docker buildx build --platform linux/amd64,linux/arm64 --push \ + -t ${{ env.REGISTRY }}/moq-cli:latest - <<'DOCKERFILE' + FROM busybox:stable + ENTRYPOINT ["sh", "-c", "echo 'error: moqdev/moq-cli is now moqdev/moq' >&2; exit 1"] + DOCKERFILE diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml index 139a60cfeb..4433d39ec8 100644 --- a/.github/workflows/obs.yml +++ b/.github/workflows/obs.yml @@ -15,7 +15,8 @@ name: OBS # libmoq's own C fixtures (`just rs c-tests`) run here too. They link libmoq.a # from outside cargo the same way, which is too slow for nextest's hang budget # and needs the debug staticlib this job has just built, so this is the one -# PR gate that already has everything they need. +# PR gate that already has everything they need. They also compile the C doc +# samples, hence `doc/lib/c/**` in the trigger. # # The manifests and Cargo.lock are in there for the one way a crate the plugin # never names can break it: linking libmoq.a from outside cargo needs the native @@ -49,6 +50,8 @@ on: paths: - "cpp/obs/**" - "rs/libmoq/**" + - "doc/lib/c/**" + - "doc/lib/samples.sh" - "rs/**/Cargo.toml" - "rs/**/build.rs" - "Cargo.toml" diff --git a/.github/workflows/release-winget.yml b/.github/workflows/release-winget.yml index 4b0e62aba6..7e069b78d8 100644 --- a/.github/workflows/release-winget.yml +++ b/.github/workflows/release-winget.yml @@ -86,7 +86,7 @@ jobs: crate="${BASH_REMATCH[1]}" version="${BASH_REMATCH[2]}" case "$crate" in - moq-cli) identifier="moq-dev.moq-cli" ;; + moq-cli) identifier="moq-dev.moq" ;; moq-relay) identifier="moq-dev.moq-relay" ;; *) echo "No winget identifier mapped for crate: $crate" >&2 diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 7092f0d955..8c3cdcbfab 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -14,6 +14,8 @@ on: branches: [main] paths: - "swift/**" + - "doc/lib/swift/**" + - "doc/lib/samples.sh" - "rs/moq-ffi/**" - "rs/moq-video/**" - "Cargo.lock" @@ -24,6 +26,8 @@ on: types: [opened, synchronize, reopened, closed] paths: - "swift/**" + - "doc/lib/swift/**" + - "doc/lib/samples.sh" - "rs/moq-ffi/**" - "rs/moq-video/**" - "Cargo.lock" diff --git a/Cargo.lock b/Cargo.lock index ab053a33f4..c648a8d0a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,9 +1613,9 @@ dependencies = [ [[package]] name = "cudarc" -version = "0.19.9" +version = "0.19.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804764d10e844da09765a7b2ca9641a0851523d1702efb0d7299d73e31b86e80" +checksum = "7359bf1de037ddada72729c3acec9b040f3468400c6e9e740b01ba0cd2e89562" dependencies = [ "libloading 0.9.0", ] @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.1" +version = "0.21.2" dependencies = [ "anyhow", "bytes", @@ -3086,16 +3086,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -3883,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "bytes", @@ -4154,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.1" +version = "0.0.2" dependencies = [ "async-trait", "bytes", @@ -4171,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.0.27" +version = "0.1.1" dependencies = [ "block2 0.6.2", "bytes", @@ -4250,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bytes", "kio 0.6.0", @@ -4262,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "boytacean", @@ -4282,11 +4283,12 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.1" +version = "0.12.2" dependencies = [ "anyhow", "axum", "axum-server", + "base64 0.23.1", "bytes", "hang", "humantime", @@ -4319,7 +4321,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.1" +version = "0.0.2" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4338,7 +4340,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.1" +version = "0.4.2" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4372,7 +4374,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "bytes", @@ -4390,7 +4392,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.1" +version = "0.5.2" dependencies = [ "axum", "bytes", @@ -4404,6 +4406,7 @@ dependencies = [ "percent-encoding", "rand 0.10.3", "reqwest", + "sha2", "thiserror 2.0.21", "tokio", "tower", @@ -4413,7 +4416,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.4.1" +version = "0.4.2" dependencies = [ "bytes", "criterion", @@ -4430,7 +4433,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.9" +version = "0.2.10" dependencies = [ "bytes", "moq-net", @@ -4448,7 +4451,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.1" +version = "0.10.2" dependencies = [ "anyhow", "base64 0.23.1", @@ -4485,7 +4488,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.0" +version = "0.3.1" dependencies = [ "arrayvec", "bytes", @@ -4584,7 +4587,7 @@ dependencies = [ [[package]] name = "moq-nvenc" -version = "0.0.6" +version = "0.1.0" dependencies = [ "cudarc", "libloading 0.9.0", @@ -4602,7 +4605,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.1" +version = "0.15.2" dependencies = [ "anyhow", "axum", @@ -4615,11 +4618,13 @@ dependencies = [ "libc", "moq-auth", "moq-net", + "moq-shaper", "moq-sock", "moq-stats", "moq-tokio", "moq-uring", "qmux", + "rand 0.10.3", "rcgen", "reqwest", "reqwest-middleware", @@ -4643,7 +4648,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.1" +version = "0.2.2" dependencies = [ "kio 0.6.0", "moq-auth", @@ -4657,7 +4662,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.1" +version = "0.3.2" dependencies = [ "aws-lc-rs", "axum", @@ -4677,7 +4682,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.1" +version = "0.3.2" dependencies = [ "anyhow", "byteorder", @@ -4700,8 +4705,19 @@ dependencies = [ ] [[package]] -name = "moq-sock" +name = "moq-shaper" version = "0.0.1" +dependencies = [ + "anyhow", + "clap", + "humantime", + "rand 0.10.3", + "tokio", +] + +[[package]] +name = "moq-sock" +version = "0.1.0" dependencies = [ "core_affinity", "libc", @@ -4714,7 +4730,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.1" +version = "0.3.2" dependencies = [ "bytes", "futures", @@ -4730,7 +4746,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.1" +version = "0.2.2" dependencies = [ "futures", "moq-json", @@ -4745,7 +4761,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.12" +version = "0.19.13" dependencies = [ "anyhow", "bytes", @@ -4798,7 +4814,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.0.21" +version = "0.1.1" dependencies = [ "anyhow", "bytes", @@ -4817,7 +4833,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.2" +version = "0.0.3" dependencies = [ "anyhow", "bytes", @@ -4851,7 +4867,7 @@ dependencies = [ [[package]] name = "moq-v4l" -version = "0.0.1" +version = "0.1.0" dependencies = [ "bitflags 2.13.2", "libc", @@ -4859,9 +4875,9 @@ dependencies = [ [[package]] name = "moq-vaapi" -version = "0.0.4" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091df44de531fc6e5346da91f2dd7d1ebbb4a2e741ddadaf49aef017e659cb57" +checksum = "a697143830ed57c5ce0e96016e845e0b097f607c536847c12890e3bd23ff4db9" dependencies = [ "anyhow", "bindgen 0.70.1", @@ -4874,7 +4890,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.0.27" +version = "0.1.1" dependencies = [ "anyhow", "ash", @@ -10880,18 +10896,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 5a529c9cd6..6751e2b493 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "rs/moq-room", "rs/moq-rtc", "rs/moq-rtmp", + "rs/moq-shaper", "rs/moq-sock", "rs/moq-srt", "rs/moq-stats", @@ -68,6 +69,7 @@ default-members = [ "rs/moq-room", "rs/moq-rtc", "rs/moq-rtmp", + "rs/moq-shaper", "rs/moq-sock", "rs/moq-srt", "rs/moq-stats", @@ -119,7 +121,7 @@ dispatch2 = "0.3.1" flate2 = "1.1" futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.1", path = "rs/hang" } +hang = { version = "0.21.2", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -137,16 +139,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.0.27", path = "rs/moq-audio", default-features = false } +moq-audio = { version = "0.1.1", path = "rs/moq-audio", default-features = false } moq-auth = { version = "0.1.0", path = "rs/moq-auth" } -moq-binary = { version = "0.1.0", path = "rs/moq-binary" } +moq-binary = { version = "0.1.1", path = "rs/moq-binary" } moq-flate = { version = "0.1.3", path = "rs/moq-flate" } -moq-hls = { version = "0.5.1", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.4.1", path = "rs/moq-json" } -moq-loc = { version = "0.2.9", path = "rs/moq-loc" } +moq-hls = { version = "0.5.2", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.4.2", path = "rs/moq-json" } +moq-loc = { version = "0.2.10", path = "rs/moq-loc" } moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.1", path = "rs/moq-mux" } -moq-net = { version = "0.3.0", path = "rs/moq-net" } +moq-mux = { version = "0.10.2", path = "rs/moq-mux" } +moq-net = { version = "0.3.1", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. moq-noq-proto = { version = "1.3", default-features = false } @@ -154,38 +156,38 @@ moq-noq-udp = "1.3" # NVENC bindings, forked from ViliamVadocz/nvidia-video-codec-sdk to dlopen the # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. -moq-nvenc = { version = "0.0.6", path = "rs/moq-nvenc" } +moq-nvenc = { version = "0.1.0", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.1", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.1", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.1", path = "rs/moq-rtmp" } -moq-sock = { version = "0.0.1", path = "rs/moq-sock" } -moq-srt = { version = "0.3.1", path = "rs/moq-srt" } -moq-stats = { version = "0.2.1", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.12", path = "rs/moq-tokio", default-features = false } +moq-relay = { version = "0.15.2", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.2", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.2", path = "rs/moq-rtmp" } +moq-sock = { version = "0.1.0", path = "rs/moq-sock" } +moq-srt = { version = "0.3.2", path = "rs/moq-srt" } +moq-stats = { version = "0.2.2", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.13", path = "rs/moq-tokio", default-features = false } # 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.0.21", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.1", 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.2", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.3", 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. -moq-v4l = { version = "0.0.1", path = "rs/moq-v4l" } +moq-v4l = { version = "0.1.0", path = "rs/moq-v4l" } # Standalone crate (moq-dev/vaapi); vendored from cros-libva + cros-codecs. # dlopen's libva at runtime (no libva-dev at build, no NEEDED libva in the binary). -moq-vaapi = "0.0.4" +moq-vaapi = "0.1.0" # `default-features = false` is here for `capture` (V4L2, libclang), which the # cross-compiled Python, Swift, Kotlin, and Go builds have no use for: adding # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and libmoq opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.0.27", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.1", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } diff --git a/Dockerfile b/Dockerfile index c77df07b7a..e7626dbdfd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,9 +29,8 @@ RUN --mount=type=cache,target=/root/.cache --mount=type=cache,target=/nix,from=n ARG package="sh" # Create entry.sh script that knows which binary to run. Derive it from the -# single binary the package produced (so a crate whose `[[bin]]` name differs -# from its package name, e.g. `moq-cli` shipping as `moq`, just works); fall -# back to the package name when there's no binary (the `sh` default). +# single binary the package produced, so a package need not share its binary's +# name; fall back to the package name when there's no binary (the `sh` default). RUN binary="$(ls /output/result/bin 2>/dev/null | head -n1)"; \ [ -n "$binary" ] || binary="${package}"; \ printf '#!/bin/sh\nexec /bin/%s "$@"\n' "${binary}" > /output/entry.sh && \ diff --git a/dart/moq/README.md b/dart/moq/README.md index 8bd2f556aa..d1d2c02d9f 100644 --- a/dart/moq/README.md +++ b/dart/moq/README.md @@ -26,6 +26,7 @@ final server = await Server.listen( ), ); final broadcast = server.createBroadcast('live'); +broadcast.announce(route: MoqRoute()); // unannounced broadcasts are invisible await for (final request in server.requests()) { final session = await request.accept(); print(session.epoch()); diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 88b44596e1..3a821799d8 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -41,6 +41,14 @@ final class ConnectOptions { /// tracks may want this raised. final int? maxStreams; + /// Set false to stop the WebSocket fallback racing QUIC, e.g. against a + /// relay that only serves QUIC. On by default. + final bool? websocketEnabled; + + /// Head start QUIC gets before the WebSocket fallback joins the race; 200ms + /// by default, and zero races both at once. + final Duration? websocketDelay; + /// Set false for a one-shot dial. By default the session redials with /// backoff whenever the transport drops. final bool? reconnect; @@ -63,6 +71,8 @@ final class ConnectOptions { this.tlsKey, this.bind, this.maxStreams, + this.websocketEnabled, + this.websocketDelay, this.reconnect, this.backoff, this.publish, @@ -89,6 +99,15 @@ final class Moq { String url, { ConnectOptions options = const ConnectOptions(), }) async { + final websocketDelay = options.websocketDelay; + if (websocketDelay != null && websocketDelay.isNegative) { + throw ArgumentError.value( + websocketDelay, + 'websocketDelay', + 'must not be negative', + ); + } + final client = Client(); try { if (!options.tlsVerify) client.setTlsVerify(verify: false); @@ -107,6 +126,12 @@ final class Moq { if (options.maxStreams != null) { client.setQuicMaxStreams(maxStreams: options.maxStreams!); } + if (options.websocketEnabled != null) { + client.setWebsocketEnabled(enabled: options.websocketEnabled!); + } + if (websocketDelay != null) { + client.setWebsocketDelay(delayUs: websocketDelay.inMicroseconds); + } if (options.reconnect != null) { client.setReconnect(enabled: options.reconnect!); } @@ -126,7 +151,7 @@ final class Moq { } } - /// Create an unadvertised broadcast at [path]. + /// Create an unannounced broadcast at [path], invisible to everyone until announced. /// /// Advertise it with `announce` after populating tracks. Create, `dynamic` /// if tracks are served on demand, populate, then announce. diff --git a/dart/moq/pubspec.yaml b/dart/moq/pubspec.yaml index da2d73de72..015dd85d2f 100644 --- a/dart/moq/pubspec.yaml +++ b/dart/moq/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: # The published floor, bumped by hand when moq starts relying on newer # bindings. In this checkout the override wins and pub ignores the # constraint, so the path package's 0.0.0-dev sentinel resolves fine. - moq_ffi: ^0.4.0 + moq_ffi: ^0.4.3 dependency_overrides: moq_ffi: diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index e168ee7d8c..8fcc824ab2 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -77,7 +77,8 @@ void main() { return request.accept().timeout(timeout); }(); - // A one-shot dial with explicit pacing: both knobs reach the FFI client. + // A one-shot QUIC-only dial with explicit pacing: every knob reaches the + // FFI client, and QUIC alone still connects. final client = await Moq.connect( 'https://${server.localAddr}', options: ConnectOptions( @@ -85,6 +86,8 @@ void main() { bind: '127.0.0.1:0', reconnect: false, backoff: Backoff(initialUs: 1000, maxUs: 2000, timeoutUs: 3000), + websocketEnabled: false, + websocketDelay: Duration.zero, ), ).timeout(timeout); final serverSession = await accepted; @@ -103,6 +106,18 @@ void main() { server.close(); }); + test('a negative websocket delay is refused before dialing', () { + expect( + Moq.connect( + 'https://localhost', + options: const ConnectOptions( + websocketDelay: Duration(microseconds: -1), + ), + ), + throwsArgumentError, + ); + }); + test('closing a server releases its port', () async { final first = await Server.listen( options: const ListenOptions( @@ -138,32 +153,35 @@ void main() { ); }); - test('local discovery survives unannounce until finish', () async { + test('a broadcast is reachable only while announced', () async { final origin = MoqOriginProducer(config: MoqOriginConfig()); final broadcast = origin.createBroadcast(path: 'live'); broadcast.publishTrack(name: 'events', info: null); final consumer = origin.consume(); - final announced = consumer.announced(config: MoqAnnounceConfig()); - final created = await announced.next().timeout(timeout); - expect(created?.prefix(), 'live'); - expect(created?.active(), isTrue); - expect(created?.route().cost, 0); + await expectLater( + consumer.requestBroadcast(path: 'live').timeout(timeout), + throwsA(anything), + ); - broadcast.announce(route: MoqRoute(cost: 3)); - final advertised = await announced.next().timeout(timeout); - expect(advertised?.active(), isTrue); - expect(advertised?.route().cost, 3); + broadcast.announce(route: MoqRoute()); + final announced = consumer.announced(config: MoqAnnounceConfig()); + final first = await announced.next().timeout(timeout); + expect(first?.prefix(), 'live'); + expect(first?.active(), isTrue); broadcast.unannounce(); - final local = await announced.next().timeout(timeout); - expect(local?.active(), isTrue); - expect(local?.route().cost, 0); - await consumer.requestBroadcast(path: 'live').timeout(timeout); - - broadcast.finish(); final retracted = await announced.next().timeout(timeout); expect(retracted?.prefix(), 'live'); expect(retracted?.active(), isFalse); + await expectLater( + consumer.requestBroadcast(path: 'live').timeout(timeout), + throwsA(anything), + ); + + broadcast.announce(route: MoqRoute()); + final back = await announced.next().timeout(timeout); + expect(back?.active(), isTrue); + await consumer.requestBroadcast(path: 'live').timeout(timeout); announced.cancel(); announced.dispose(); }); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 40ad78d490..92e68c91cb 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,7 +8,9 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; + import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -7506,6 +7508,8 @@ abstract class MoqClientInterface { void setTlsRoots({required List paths}); void setTlsSystemRoots({required bool systemRoots}); void setTlsVerify({required bool verify}); + void setWebsocketDelay({required int delayUs}); + void setWebsocketEnabled({required bool enabled}); } final _MoqClientFinalizer = Finalizer>((ptr) { @@ -7677,6 +7681,26 @@ class MoqClient implements MoqClientInterface { ); }, moqExceptionErrorHandler); } + + void setWebsocketDelay({required int delayUs}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqclient_set_websocket_delay( + uniffiClonePointer(), + FfiConverterUInt64.lower(delayUs), + status, + ); + }, moqExceptionErrorHandler); + } + + void setWebsocketEnabled({required bool enabled}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqclient_set_websocket_enabled( + uniffiClonePointer(), + FfiConverterBool.lower(enabled), + status, + ); + }, moqExceptionErrorHandler); + } } class FfiConverterMoqClient { @@ -11154,6 +11178,24 @@ external void uniffi_moq_ffi_fn_method_moqclient_set_tls_verify( Pointer uniffiStatus, ); +@Native, Uint64, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqclient_set_websocket_delay( + Pointer ptr, + int delay_us, + Pointer uniffiStatus, +); + +@Native, Int8, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqclient_set_websocket_enabled( + Pointer ptr, + int enabled, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -12073,6 +12115,12 @@ external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_system_roots(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclient_set_tls_verify(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqclient_set_websocket_delay(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqclient_set_websocket_enabled(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_bandwidth(); @@ -12306,18 +12354,18 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced_broadcast() != - 16445) { + 8509) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_request_broadcast() != - 18586) { + 64026) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqorigindynamic_cancel() != 47453) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqorigindynamic_requested_broadcast() != - 53391) { + 54021) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqorigindynamic_update() != 27700) { @@ -12327,7 +12375,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast() != - 47748) { + 48971) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_dynamic() != 56233) { @@ -12348,7 +12396,7 @@ void _checkApiChecksums() { 47317) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 14026) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume() != 27634) { @@ -12405,7 +12453,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_unannounce() != - 39609) { + 63513) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqcontainerproducer_cut() != 17534) { @@ -12646,6 +12694,13 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclient_set_tls_verify() != 64525) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqclient_set_websocket_delay() != 53033) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqclient_set_websocket_enabled() != + 65261) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqsession_bandwidth() != 8006) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 53fdc40395..2174f1dcdc 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -76,6 +76,7 @@ export default defineConfig({ { text: "Install", link: "/setup/install" }, { text: "Development", link: "/setup/dev" }, { text: "Production", link: "/setup/prod" }, + { text: "Upgrade", link: "/setup/upgrade" }, { text: "Coding agents", link: "/setup/agent" }, ], }, diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 54e9a13e8c..01f38243ac 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -23,7 +23,8 @@ or Docker; see [Install](/setup/install). | `export` | `rtmp`, `srt`, `rtc` | Serve plays (`--listen`) or push to a remote (`--connect`). | | `play` | | Decode and play in a native window with sound. | | `transcode` | | Publish a just-in-time rendition ladder next to a broadcast. | -| `token` | | Generate, sign, and verify relay JWTs. | +| `fetch` | `` | Write one group of a track to stdout. | +| `auth` | | Generate, sign, and verify relay JWTs. | | `devices` | | List capture sources and their ids. | ## Grammar @@ -32,6 +33,7 @@ or Docker; see [Install](/setup/install). moq import [options] moq export [options] moq play [options] +moq fetch [options] ``` The **MoQ side** goes first and attaches the process to the network: @@ -155,6 +157,27 @@ heights and bitrates must then increase strictly together. Duplicate heights or bitrates, inverted rankings, and zero-sized or zero-bitrate rungs are rejected before connecting. +## Fetch + +```bash +moq --connect https://relay.example.com/anon --broadcast my-stream.hang fetch catalog.json | jq +moq ... fetch video/hd --group 42 --json +``` + +Writes one group of a track to stdout over MoQ, with the session's own auth: +the counterpart of the relay's HTTP `/fetch//?group=N`. Without +`--group` it reads the newest group. By default stdout carries the frame +payloads back to back, byte for byte what `curl` gets from `/fetch`. `--json` +prints one line per frame instead: +`{"group": 42, "frame": 0, "size": 1234, "payload": ""}`, with a +zero-based `frame` and padded standard base64. + +`` is the literal track name. `/fetch` splits its path on the last `/`, +so the two agree only for names without one. Fetch only dials `--connect`, and +refuses a listener or cluster flag. It gives up after 30 seconds, as `/fetch` +does, and exits non-zero when the broadcast or group is not found, the relay +refuses, or the deadline passes. + ## Multiple stages Separate stages with `--` to bridge several broadcasts, or both directions, diff --git a/doc/bin/hls.md b/doc/bin/hls.md index 9d6e28e1c9..d8b169ffdf 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -31,14 +31,22 @@ broadcast by path: ```text /{broadcast}/master.m3u8 +/{broadcast}/manifest.mpd /{broadcast}/{video|audio}/{rendition}/media.m3u8 -/{broadcast}/{video|audio}/{rendition}/init.mp4 +/{broadcast}/{video|audio}/{rendition}/init.{hash}.mp4 /{broadcast}/{video|audio}/{rendition}/seg/{segment}.m4s +/{broadcast}/{video|audio}/{rendition}/seg/t{pts}.m4s ``` +The init URL carries a hash of its bytes, so a reconfigured rendition gets a +new one. An embedder of the library can also label the publisher's run with +`Broadcaster::set_generation`. Every segment URL then carries it +(`seg/{generation}.{segment}.m4s`), since a restarted publisher reuses segment +numbers for different media. + `--window` sets the playlist duration (default 16 s), `--listen-tls-cert`/`--listen-tls-key` or `--listen-tls-generate` serve HTTPS, and `--cors-origin` opens it to browsers. H.264/H.265 and AAC/Opus renditions are served. Import handles classic HLS; -LL-HLS parts and DASH output are not implemented yet. The library is +LL-HLS parts are not implemented yet. The library is [`moq-hls`](https://docs.rs/moq-hls). diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 537be25703..0134631682 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -42,8 +42,10 @@ received. `**` is everything, an empty list is nothing), `root` (optional; replaces the dialed path, which is how a slug aliases to a canonical id), `expires` (optional unix seconds; the session closes then), `revalidate` (optional -seconds until the relay asks again), and `tier` (optional label handed to -[stats](/bin/relay/config#stats)). A 2xx with a grant admits. A 401 or 403 refuses. +seconds until the relay asks again), `tier` (optional label handed to +[stats](/bin/relay/config#stats)), and `peer` (optional; `true` marks another +relay, so what it announces counts as entering the cluster elsewhere, not as +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 @@ -52,9 +54,10 @@ as invalid. A few seconds of clock skew are tolerated on `expires`. **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 what the session holds closes it with `Unauthorized` (the live session is not -resized in place); a changed `tier` is logged and -applies to the session's next connection, since its stats counters were -resolved at admission. A 401 or 403 closes the session now, as does a 2xx +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 +on, as does each group and subscription it starts afterwards, while one already +in flight finishes where it began. A 401 or 403 closes the session now, as does a 2xx whose grant names nothing. A 2xx that fails validation otherwise (already expired, `revalidate` without `expires`, or a zero cadence) closes it as `invalid`. Anything else (408, 429, 404, 400, 5xx, a timeout, a transport diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 003edca212..ddeb3ae081 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -183,5 +183,12 @@ the relay minted for itself and checks locally, and never receive `cluster.token`. Dials retry forever with capped backoff, so a rejected peer is loud in the logs rather than fatal. See [Authentication](/bin/relay/auth#mtls). +A relay records whether each route entered here or came from a peer, which the +hop list alone cannot say: a client and a peer each add one hop. Routes over a +dial this relay made, and over an accepted LAN peer, count as a peer's. An +accepted peer counts only when its grant sets `peer: true`; otherwise it looks +like a client ingesting here. An embedder reads this as `Route::source()` and +filters with `origin::Consumer::local()`. + The `/nodes` [internal endpoint](/bin/relay/http#get-nodes) shows the cluster as this relay sees it. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6c1fd65b96..1423170ed6 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -54,6 +54,15 @@ anonymous mark and travels the chain unchanged. A route that passed through an anonymous hop at any depth ranks below every fully identified route, whatever the costs say; among anonymous routes, cost keeps ordering. +A broadcast exists only while it is announced, for consumers in the same +process and across a session alike: one that is created but never announced +can be neither discovered nor requested. A broadcast published locally +competes with remote routes to its path on cost like any other route, winning +only a tie. Retracting a route (an unannounce, or the peer's `ANNOUNCE_END`) +stops new requests from resolving through it but leaves subscriptions already +in flight alone: each track runs to its own end, the publisher's FIN or reset. +moq-transport sessions behave the same when a namespace is withdrawn. + ## Path patterns Rust's `moq_net::Pattern` and TypeScript's `Path.Pattern` from `@moq/net` diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 1503770533..f820be3aad 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -35,12 +35,14 @@ and `target/include/moq.h`. - **Threading.** Any function from any thread. Raw publish calls block until the codec takes the frame, which paces a publisher. - **Connection health.** `moq_session_stats()` reports available metrics with per-field validity flags. `moq_session_snapshot()` samples those metrics and the negotiated draft name together from the same connection. Its protocol string is backed by static storage. Both return an offline error between reconnects and leave the destination untouched. `moq_session_bandwidth()` mints an allocator over the send estimate; `moq_bandwidth_reserve` claims a share for an app-owned track, and `moq_encode_video` / `moq_encode_audio` take the same handle so the built-in video encoder follows the grant. - **Raw playback.** Raw audio and video consumers start at the newest cached group when opened, so rebuilding a live decoder skips the retained backlog. +- **Raw audio encode.** `moq_audio_encoder_output.codec` names the codec (only `"opus"` today), and `frame_duration_us` sets the Opus frame length: 2500, 5000, 10000, 20000, 40000, or 60000, with 0 meaning the 20 ms default. - **Raw decode output.** `moq_video_decoder_output` selects the decoded CPU pixel format (`MOQ_VIDEO_PIXEL_FORMAT_I420` or `_RGBA`) and target size (`width`/`height`, both zero for native; otherwise even and non-zero). Unknown formats and invalid sizes fail `moq_decode_video` before subscribing; accepted requests deliver exactly that layout or fail on the terminal callback. - **Encoded video metadata.** `moq_video_init.hint` is a zero-initialized `moq_video_hint` with `has_*` flags for coded dimensions, bitrate (bits per second), frame rate, and latency preference. Hints seed a video codec track's catalog; detected dimensions take precedence. -- **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, the Happy Eyeballs delays, `websocket_enabled`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. +- **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, the Happy Eyeballs delays, `websocket_enabled`/`_delay_us`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. +- **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (locally discoverable producer), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. ```c moq_client_config config; diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index a0405fa9e9..c3dfd82dae 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -55,6 +55,7 @@ final server = await Server.listen( ), ); final live = server.createBroadcast('live/camera'); +live.announce(route: MoqRoute()); // unannounced broadcasts are invisible await for (final request in server.requests()) { final session = await request.accept(); print(session.epoch()); @@ -62,7 +63,7 @@ await for (final request in server.requests()) { ``` The three advertising operations: `moq.createBroadcast(path)` (or -`origin.createBroadcast`) returns a locally discoverable producer; +`origin.createBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / `broadcast.unannounce()` own that exact-path advertisement; `origin.dynamic_(prefix:, route:)` claims `prefix` and every path beneath it (`''` for everything; Dart spells the origin method @@ -79,6 +80,10 @@ and `backoff:` re-paces the retries. `moq.epoch` counts the connections, 1 on th `session.status()` to log each reconnect; `maxStreams` raises the peer's inbound stream cap for a subscriber to many tracks. +The [WebSocket fallback](/concept/transport#websocket-fallback) races QUIC after +a 200 ms head start. `websocketEnabled: false` turns it off for a QUIC-only +relay, and a `websocketDelay` `Duration` changes the head start. + Types are spelled without the `Moq` prefix (`Session`, `BroadcastProducer`, `Backoff`); the generated names stay valid, since these are aliases rather than wrappers. `Container`, `Route`, and the exceptions keep theirs, because diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 81e3b1833b..80cfb212d6 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -70,7 +70,7 @@ broadcast.Finish() // keep the producer reachable while publishing, then finis ``` The three advertising operations: `client.CreateBroadcast(path)` (or -`origin.CreateBroadcast`) returns a locally discoverable producer; +`origin.CreateBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.Announce(route)` / `broadcast.Unannounce()` own that exact-path advertisement; `origin.Dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` @@ -95,6 +95,10 @@ each reconnect by number; `moq.WithBackoff` tunes the pacing, with `moq.RetryForever` as the timeout; and `moq.WithQUICMaxStreams` raises the peer's inbound stream cap for a subscriber to many tracks. +The [WebSocket fallback](/concept/transport#websocket-fallback) races QUIC after +a 200 ms head start. `moq.WithWebSocketEnabled(false)` turns it off for a +QUIC-only relay, and `moq.WithWebSocketDelay` changes the head start. + `moq.Listen` accepts sessions with per-request `Accept`/`Reject`; `Request.Transport()` returns the closed `moq.Transport` enum. `Request.SetPublish`/`SetConsume` return an error if the request is already diff --git a/doc/lib/index.md b/doc/lib/index.md index 24dcfb9cf2..27eaf5edd1 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -35,7 +35,7 @@ how it looks in that language: - **Connect** to a relay with TLS options (system roots, custom CA, fingerprint pinning, mTLS) and a JWT in the URL, or **serve** sessions yourself and accept or reject each request by path. - **Reconnect** automatically with backoff when the transport drops, with `status`/`epoch` reporting each (re)connect and backoff tunable down to retrying forever. The peer's inbound QUIC stream limit is configurable for subscribe-heavy clients. -- **Discover** broadcasts by prefix, wait for a specific one, or request an unannounced one. Create a locally discoverable exact path with `create_broadcast`, then advertise it to peers with `announce` / `unannounce`, or claim a path prefix with `dynamic(prefix, route)`. +- **Discover** broadcasts by prefix, wait for a specific one, or request one by path, including a path a prefix claim serves on demand. Advertise an exact path with `create_broadcast` then `announce` / `unannounce` (a broadcast is invisible to local consumers and peers alike until announced), or claim a path prefix with `dynamic(prefix, route)`. - **Publish and subscribe to media** with the hang catalog filled in from the bitstream, plus raw pixels or PCM in and out with the codec running inside the binding (VideoToolbox, Media Foundation, NVENC, openh264, Opus). A publisher follows the connection's send estimate through `session.bandwidth()`: reserve a share for an app-owned encoder, or pass the handle when encoding so the built-in video encoder follows the grant. - **Connection health.** `stats()` snapshots RTT, send/receive estimates, and byte/packet counters. `bandwidth()` divides that send estimate among tracks sharing the connection. - **Raw tracks** of arbitrary bytes with timestamps, sparse or replayed groups, per-subscriber priority and max age, and best-effort datagrams. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 0802896244..0ea3b7c819 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -47,10 +47,10 @@ for (;;) { } ``` -- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` prefers a local broadcast, so a page that watches what it publishes reads its own copy with no round trip. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and announcing advertises the path to peers. +- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` resolves an announced local broadcast with no round trip, so a page that watches what it publishes reads its own copy, unless a cheaper route announces the same path. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and nobody, local or remote, can see or reach a broadcast until it announces. - **Connections** race WebTransport against WebSocket. `new Connection({ url })` pools one connection per relay URL and reconnects with backoff, which the elements use. Supplying WebTransport/WebSocket options, discovery, delay, or a caller-owned origin selects a private loop; explicit `share: true` refuses those options. `closed` settles when the handle is released (`null` on a clean close); the failure that stopped retrying the current URL is `error`, and a new URL recovers the same handle. A connection owns one send-rate sampler and one `Bandwidth.Allocator`; publishers reserve against it so their encoder targets sum to the estimate instead of each matching it. - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. -- **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `path` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. +- **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. @@ -86,19 +86,19 @@ Moq.Path.Pattern.parse("camera-*").rooted("room").text; // "room/camera-*" Three operations, on an origin: - `origin.createBroadcast(path)` returns a producer. The broadcast is - reachable and visible to local discovery immediately. Peers see it only after + invisible and unreachable, for local consumers and peers alike, until `broadcast.announce()`. - `broadcast.announce(route)` / `broadcast.unannounce()` own that advertisement. Announcing again re-prices the standing route. - `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` claims everything). Hold the returned `Origin.Dynamic` while the claim should stay advertised; `close()` retracts it. A request beneath it - with no local broadcast is an `Origin.Request` to `accept` or `reject`; + that no announced local broadcast wins is an `Origin.Request` to `accept` or `reject`; reject what you will not serve rather than narrowing the claim, since a route is always a prefix on every wire. A route is a capability, not an inventory. `origin.announced(scope)` yields -`Announce.Update` values: `path` is the covered prefix relative to the origin, +`Announce.Update` values: `prefix` is the covered prefix relative to the origin, `captures` is one pattern per scope wildcard when the prefix pins a complete match (otherwise `undefined`), `kind` is `"announced"`, `"updated"` (a reprice in place), or `"retracted"`, and `route` carries hops and cost (on a diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index fabf01206c..2af452cb20 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -31,7 +31,7 @@ WebCodecs, writes the catalog, and publishes a hang broadcast. | `source` | `camera`, `screen`, or `file`. | | `muted`, `invisible` | Disable audio or video capture. | | `preview` | What the nested element shows: the raw `source` (default), a decoded copy of the `encoded` stream to see what viewers get, or `none`. | -| `announce` | When to advertise: once a `source` is live (default), `always`, or `never`. A camera source waits for every enabled track. The broadcast is created while connected either way; this only flips discoverability. | +| `announce` | When to advertise: once a `source` is live (default), `always`, or `never`. A camera source waits for every enabled track. The broadcast is created while connected either way, but nobody can see or subscribe to it until it is announced. | A nested `