diff --git a/.claude/skills/spawn-merge/SKILL.md b/.claude/skills/spawn-merge/SKILL.md index 6549ea57d3..94e4ede39b 100644 --- a/.claude/skills/spawn-merge/SKILL.md +++ b/.claude/skills/spawn-merge/SKILL.md @@ -8,7 +8,7 @@ Read the /merge, /takeover, and /close skills before starting. The goal is to evaluate the open PRs in the repository and decide which ones to merge. Each merge is performed in parallel by a sub-agent. -Start by listing all open PRs. +Start by listing all open PRs that are ready for review (skip drafts). An argument can be used to filter the PRs in scope. One at a time, for each PR, interactively prompt the user if we should /merge, skip, or /close. diff --git a/.claude/skills/start-quest/SKILL.md b/.claude/skills/start-quest/SKILL.md index 35d5078967..4ec47379e5 100644 --- a/.claude/skills/start-quest/SKILL.md +++ b/.claude/skills/start-quest/SKILL.md @@ -5,14 +5,18 @@ description: Start work on a quest. Before you begin, read `quest/CLAUDE.md` completely. -Your goal is to implement the quest, or as much of it as possible, and create a PR. +Your goal is to implement the quest, or as much of it as possible, and create a draft PR. The argument is the quest to work on. If you are unsure on the best course of action, ask the user for direction. Confirm the quest is ready and unclaimed. Claim it as `quest/CLAUDE.md` describes: `quest branch` names the branch and its bases, missing line branches get a draft PR, and the quest branch gets an empty commit. + Implement the quest until it is complete, or some blocker is hit, then create a PR against the base. 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. -Summarize the notable changes for the user. + +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.md b/CLAUDE.md index 504b71a14b..ba39b50d64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ This file is split into nested `CLAUDE.md` files based on the language/situation - Try to do stuff asynchronously. ex. ask about follow-ups while tests run. - Try to recognize when you're stuck, or making minimal progress, and stop early. - If the core problem is addressed, ship it instead of spinning your wheels on meaningless revisions. -- Benchmark any performance optimizations instead of relying on intuition. +- Add or extend a benchmark for any performance-sensitive change so later regressions show up, and measure optimizations instead of relying on intuition. # Public API diff --git a/Cargo.lock b/Cargo.lock index 4d2368190d..55b36549aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,9 +1015,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -2260,9 +2260,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixed-resample" @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.3" +version = "0.21.5" dependencies = [ "anyhow", "bytes", @@ -3525,9 +3525,9 @@ dependencies = [ [[package]] name = "is" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08f9118d003d441f79c1070e84d0a2a89f35721ca8ae0cd5e1f9026dc4a2517" +checksum = "b6edbbdc611843e8c65878ee735f68c495cede11ae5a9582b17fe0369c456ef5" dependencies = [ "crc", "serde", @@ -3884,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.3" +version = "0.6.5" dependencies = [ "anyhow", "bytes", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.3" +version = "0.0.5" dependencies = [ "async-trait", "bytes", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.2" +version = "0.1.4" dependencies = [ "block2 0.6.2", "bytes", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "moq-auth" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "aws-lc-rs", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.2" +version = "0.1.4" dependencies = [ "bytes", "kio 0.6.0", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.3" +version = "0.5.5" dependencies = [ "anyhow", "boytacean", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.3" +version = "0.12.5" dependencies = [ "anyhow", "axum", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.3" +version = "0.0.5" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.3" +version = "0.4.5" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.3" +version = "0.4.5" dependencies = [ "anyhow", "bytes", @@ -4393,7 +4393,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.3" +version = "0.5.5" dependencies = [ "axum", "bytes", @@ -4417,7 +4417,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.5.0" +version = "0.5.2" dependencies = [ "bytes", "criterion", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.11" +version = "0.2.13" dependencies = [ "bytes", "moq-net", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.3" +version = "0.10.5" dependencies = [ "anyhow", "base64 0.23.1", @@ -4489,7 +4489,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.2" +version = "0.3.4" dependencies = [ "arrayvec", "bytes", @@ -4588,7 +4588,7 @@ dependencies = [ [[package]] name = "moq-nvenc" -version = "0.1.0" +version = "0.1.1" dependencies = [ "cudarc", "libloading 0.9.0", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.3" +version = "0.15.5" dependencies = [ "anyhow", "axum", @@ -4649,7 +4649,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.3" +version = "0.2.5" dependencies = [ "kio 0.6.0", "moq-auth", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.3" +version = "0.3.5" dependencies = [ "aws-lc-rs", "axum", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.3" +version = "0.3.5" dependencies = [ "anyhow", "byteorder", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.3" +version = "0.3.5" dependencies = [ "bytes", "futures", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.3" +version = "0.2.5" dependencies = [ "futures", "moq-json", @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.14" +version = "0.19.16" dependencies = [ "anyhow", "bytes", @@ -4815,7 +4815,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.2" +version = "0.1.4" dependencies = [ "anyhow", "bytes", @@ -4834,7 +4834,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.4" +version = "0.0.6" dependencies = [ "anyhow", "bytes", @@ -4891,7 +4891,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.2" +version = "0.1.4" dependencies = [ "anyhow", "ash", @@ -8020,9 +8020,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -8041,9 +8041,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smawk" @@ -10897,18 +10897,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.58" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.58" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 3e25cae5ee..c8d27c0f07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,7 +121,7 @@ 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.3", path = "rs/hang" } +hang = { version = "0.21.5", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -139,16 +139,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.1.2", path = "rs/moq-audio", default-features = false } -moq-auth = { version = "0.1.1", path = "rs/moq-auth" } -moq-binary = { version = "0.1.2", path = "rs/moq-binary" } +moq-audio = { version = "0.1.4", path = "rs/moq-audio", default-features = false } +moq-auth = { version = "0.1.2", path = "rs/moq-auth" } +moq-binary = { version = "0.1.4", path = "rs/moq-binary" } moq-flate = { version = "0.2.0", path = "rs/moq-flate" } -moq-hls = { version = "0.5.3", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.5.0", path = "rs/moq-json" } -moq-loc = { version = "0.2.11", path = "rs/moq-loc" } +moq-hls = { version = "0.5.5", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.5.2", path = "rs/moq-json" } +moq-loc = { version = "0.2.13", path = "rs/moq-loc" } moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.3", path = "rs/moq-mux" } -moq-net = { version = "0.3.2", path = "rs/moq-net" } +moq-mux = { version = "0.10.5", path = "rs/moq-mux" } +moq-net = { version = "0.3.4", 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 } @@ -156,25 +156,25 @@ 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.1.0", path = "rs/moq-nvenc" } +moq-nvenc = { version = "0.1.1", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.3", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.3", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.3", path = "rs/moq-rtmp" } +moq-relay = { version = "0.15.5", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.5", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.5", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.3", path = "rs/moq-srt" } -moq-stats = { version = "0.2.3", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.14", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.5", path = "rs/moq-srt" } +moq-stats = { version = "0.2.5", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.16", 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.2", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.4", 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.4", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.6", path = "rs/moq-uring", default-features = false } # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. @@ -187,7 +187,7 @@ moq-vaapi = "0.1.0" # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and libmoq opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.1.2", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.4", 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/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index d4635f31c0..4ada2c2a1c 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -14,6 +14,65 @@ import "package:ffi/ffi.dart"; import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; +class MoqBinaryConfig { + final bool compression; + final String? mime; + MoqBinaryConfig({this.compression = false, this.mime = null}); +} + +class FfiConverterMoqBinaryConfig { + static MoqBinaryConfig lift(RustBuffer buf) { + return FfiConverterMoqBinaryConfig.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final compression_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final compression = compression_lifted.value; + new_offset += compression_lifted.bytesRead; + final mime_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final mime = mime_lifted.value; + new_offset += mime_lifted.bytesRead; + return LiftRetVal( + MoqBinaryConfig(compression: compression, mime: mime), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqBinaryConfig value) { + final total_length = + FfiConverterBool.allocationSize(value.compression) + + FfiConverterOptionalString.allocationSize(value.mime) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqBinaryConfig value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterBool.write( + value.compression, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalString.write( + value.mime, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqBinaryConfig value) { + return FfiConverterBool.allocationSize(value.compression) + + FfiConverterOptionalString.allocationSize(value.mime) + + 0; + } +} + class MoqFetchGroupOptions { final int priority; MoqFetchGroupOptions({this.priority = 0}); @@ -4002,6 +4061,164 @@ class FfiConverterMoqReservation { } } +abstract class MoqBinarySnapshotProducerInterface { + void finish(); + void update({required Uint8List payload}); +} + +final _MoqBinarySnapshotProducerFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer(ptr, status), + ); +}); + +class MoqBinarySnapshotProducer implements MoqBinarySnapshotProducerInterface { + late final Pointer _ptr; + MoqBinarySnapshotProducer._(this._ptr) { + _MoqBinarySnapshotProducerFinalizer.attach(this, _ptr, detach: this); + } + factory MoqBinarySnapshotProducer.lift(Pointer ptr) { + return MoqBinarySnapshotProducer._(ptr); + } + Pointer uniffiClonePointer() { + return rustCall( + (status) => + uniffi_moq_ffi_fn_clone_moqbinarysnapshotproducer(_ptr, status), + ); + } + + void dispose() { + _MoqBinarySnapshotProducerFinalizer.detach(this); + rustCall( + (status) => + uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer(_ptr, status), + ); + } + + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_finish( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + + void update({required Uint8List payload}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_update( + uniffiClonePointer(), + FfiConverterUint8List.lower(payload), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqBinarySnapshotProducer { + static MoqBinarySnapshotProducer lift(Pointer ptr) { + return MoqBinarySnapshotProducer.lift(ptr); + } + + static Pointer lower(MoqBinarySnapshotProducer value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqBinarySnapshotProducer value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqBinarySnapshotProducer.lift(pointer), 8); + } + + static int write(MoqBinarySnapshotProducer value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqBinaryStreamProducerInterface { + void append({required Uint8List payload}); + void finish(); +} + +final _MoqBinaryStreamProducerFinalizer = Finalizer>((ptr) { + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarystreamproducer(ptr, status), + ); +}); + +class MoqBinaryStreamProducer implements MoqBinaryStreamProducerInterface { + late final Pointer _ptr; + MoqBinaryStreamProducer._(this._ptr) { + _MoqBinaryStreamProducerFinalizer.attach(this, _ptr, detach: this); + } + factory MoqBinaryStreamProducer.lift(Pointer ptr) { + return MoqBinaryStreamProducer._(ptr); + } + Pointer uniffiClonePointer() { + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqbinarystreamproducer(_ptr, status), + ); + } + + void dispose() { + _MoqBinaryStreamProducerFinalizer.detach(this); + rustCall( + (status) => uniffi_moq_ffi_fn_free_moqbinarystreamproducer(_ptr, status), + ); + } + + void append({required Uint8List payload}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarystreamproducer_append( + uniffiClonePointer(), + FfiConverterUint8List.lower(payload), + status, + ); + }, moqExceptionErrorHandler); + } + + void finish() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbinarystreamproducer_finish( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqBinaryStreamProducer { + static MoqBinaryStreamProducer lift(Pointer ptr) { + return MoqBinaryStreamProducer.lift(ptr); + } + + static Pointer lower(MoqBinaryStreamProducer value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqBinaryStreamProducer value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqBinaryStreamProducer.lift(pointer), 8); + } + + static int write(MoqBinaryStreamProducer value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqBroadcastConsumerInterface { Future fetchGroup({ required String name, @@ -5891,6 +6108,14 @@ class FfiConverterMoqBroadcastDynamic { } abstract class MoqBroadcastProducerInterface { + MoqBinarySnapshotProducer publishBinarySnapshot({ + required String name, + required MoqBinaryConfig config, + }); + MoqBinaryStreamProducer publishBinaryStream({ + required String name, + required MoqBinaryConfig config, + }); MoqJsonSnapshotProducer publishJsonSnapshot({ required String name, required MoqJsonSnapshotConfig config, @@ -5963,6 +6188,40 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { ); } + MoqBinarySnapshotProducer publishBinarySnapshot({ + required String name, + required MoqBinaryConfig config, + }) { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_snapshot( + uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterMoqBinaryConfig.lower(config), + status, + ), + FfiConverterMoqBinarySnapshotProducer.lift, + moqExceptionErrorHandler, + ); + } + + MoqBinaryStreamProducer publishBinaryStream({ + required String name, + required MoqBinaryConfig config, + }) { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_stream( + uniffiClonePointer(), + FfiConverterString.lower(name), + FfiConverterMoqBinaryConfig.lower(config), + status, + ), + FfiConverterMoqBinaryStreamProducer.lift, + moqExceptionErrorHandler, + ); + } + MoqJsonSnapshotProducer publishJsonSnapshot({ required String name, required MoqJsonSnapshotConfig config, @@ -9318,6 +9577,72 @@ external void uniffi_moq_ffi_fn_method_moqreservation_update( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqbinarysnapshotproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqbinarysnapshotproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_finish( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarysnapshotproducer_update( + Pointer ptr, + RustBuffer payload, + Pointer uniffiStatus, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqbinarystreamproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqbinarystreamproducer( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarystreamproducer_append( + Pointer ptr, + RustBuffer payload, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbinarystreamproducer_finish( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -10132,6 +10457,38 @@ external Pointer uniffi_moq_ffi_fn_constructor_moqbroadcastproducer_new( Pointer uniffiStatus, ); +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_snapshot( + Pointer ptr, + RustBuffer name, + RustBuffer config, + Pointer uniffiStatus, +); + +@Native< + Pointer Function( + Pointer, + RustBuffer, + RustBuffer, + Pointer, + ) +>(assetId: _uniffiAssetId) +external Pointer +uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_binary_stream( + Pointer ptr, + RustBuffer name, + RustBuffer config, + Pointer uniffiStatus, +); + @Native< Pointer Function( Pointer, @@ -11620,6 +11977,18 @@ external int uniffi_moq_ffi_checksum_method_moqreservation_grant(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqreservation_update(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_finish(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_update(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_append(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_finish(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group(); @@ -11814,6 +12183,14 @@ external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel(); external int uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track(); +@Native(assetId: _uniffiAssetId) +external int +uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_snapshot(); + +@Native(assetId: _uniffiAssetId) +external int +uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_stream(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot(); @@ -12186,6 +12563,21 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqreservation_update() != 9626) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_finish() != + 10338) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarysnapshotproducer_update() != + 56077) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_append() != 1645) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbinarystreamproducer_finish() != + 60630) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group() != 18633) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); @@ -12388,12 +12780,20 @@ void _checkApiChecksums() { 24118) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_snapshot() != + 6748) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_binary_stream() != + 58418) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot() != - 51036) { + 64276) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream() != - 47317) { + 54975) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { diff --git a/doc/.vitepress/config.ts b/doc/.vitepress/config.ts index 2174f1dcdc..5b6eb122e7 100644 --- a/doc/.vitepress/config.ts +++ b/doc/.vitepress/config.ts @@ -92,6 +92,7 @@ export default defineConfig({ { text: "moq-lite", link: "/concept/moq-lite" }, { text: "hang", link: "/concept/hang" }, { text: "Audio jitter", link: "/concept/audio-jitter" }, + { text: "Stats", link: "/concept/stats" }, { text: "Standards", link: "/concept/standard" }, { text: "Use cases", diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 2c5f49c738..96f9ab82bb 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -53,6 +53,13 @@ moq --connect https://relay.example.com/anon --broadcast my-stream.hang export t moq --connect "https://relay.example.com/rooms/1?jwt=$TOKEN" --broadcast alice.hang import ts ``` +The `ts`, `fmp4`, and `flv` imports publish on the broadcast clock the catalog +advertises, not the input's own timestamps. The first frame is stamped when it +arrives, every track keeps its offset from the others, and an input that +restarts its timestamps, such as a restarted encoder, continues forward +after the real gap rather than rewinding. So a feed whose PTS starts hours in, +or whose first frame arrives late, still names the right wall time. + MPEG-TS import carries H.264/H.265 and AAC/MP2/AC-3/E-AC-3, passes SCTE-35 and subtitle PIDs through as tracks, and round-trips the service tables. A `discontinuity_indicator` on the program's PCR PID is a system time-base reset, diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index e60965a359..99bc063da5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -21,7 +21,7 @@ nix shell github:moq-dev/moq#moq-gst --command gst-launch-1.0 -e \ # Publish a test pattern gst-launch-1.0 -e videotestsrc is-live=true ! x264enc tune=zerolatency ! h264parse \ ! video/x-h264,stream-format=byte-stream,alignment=au ! mux.sink_0 \ - moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang + moqsink name=mux url=https://cdn.moq.dev/anon broadcast=.hang sink_0::encoder=true ``` Install via `apt install gstreamer1.0-moq` or `dnf install gstreamer1-moq` @@ -48,7 +48,8 @@ directly. A cue with no duration is dropped rather than left on screen. Each `sink_%u` request pad is one track. Pad properties: `track` names it (default: after the codec), `container=loc` publishes it as -[LOC](/concept/standard#loc) instead of the legacy hang container, and +[LOC](/concept/standard#loc) instead of the legacy hang container, +`encoder=true` marks it as fed by a local encoder, and `track-status`/`track-error` report its lifecycle. Element properties: `url`, `broadcast`, `tls-disable-verify`, `quic-idle-timeout`, `quic-keep-alive`, and read-only `status`, `connected`, `moq-version`, and @@ -73,6 +74,14 @@ while connected and 0 otherwise, reconnects are `started - 1` once `started` is at least 1, and a rate is the delta over any window you sample. Unlike `status`, a connection that drops before you poll still moves both counters. +Set `encoder=true` on audio and video pads a local encoder feeds +(`x264enc`, `opusenc`, ...). The pad then measures how late each frame reaches +the sink behind its running time and raises the catalog `jitter` by the spread, +so players buffer for an encoder that delivers irregularly. Leave it off, the +default, for file, demuxed, and network media: their arrival reflects the disk +or the network, not the original encoder, and a GStreamer segment cannot tell +the two apart. Text and opaque pads refuse it. + ## moqsrc Pads are named by kind and appear as the catalog announces renditions: diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 48bc233865..555f0d6c90 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -154,7 +154,12 @@ after which it can never sign a broader token. | `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. | -A token carrying the retired `put` and `get` prefix lists fails verification. +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 +grant is a subtree, signing writes that older form so relays and auth servers +that predate patterns accept it too. A grant only a pattern can express (an +exact `foo`, or `*/chat`) is written as `publish`/`subscribe`, which an older +verifier refuses. ### Path matching diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 75d792c36f..6f8677ac36 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -201,19 +201,12 @@ node = "sjc/1" # Disambiguates relays sharing a cluster. depth = 1 # Also bucket by the first N path segments (per tenant). ``` -Each stats broadcast carries `publisher.json`, `subscriber.json`, and -`sessions.json` tracks (plus compressed `.z` twins) with cumulative counters -per broadcast. Every counter pair is `*_started` / `*_ended`: -`announces_started` / `announces_ended`, `broadcasts_started` / -`broadcasts_ended`, `subscriptions_started` / `subscriptions_ended`, and -`sessions_started` / `sessions_ended`. A live count is started minus ended. -This release also writes the previous `announced` / `*_closed` spellings beside -the new names so an older consumer still reads a new relay; a new consumer -accepts either spelling, with the canonical name winning. Payload counters -(bytes, frames, groups, datagrams) are unchanged. Traffic is split by an -arbitrary **tier** label chosen by the auth server's grant or `--cluster-tier`, -which is what makes billing per customer or per region possible. Read them with -the [`moq-stats`](https://docs.rs/moq-stats) crate. +Each node publishes `publisher.json`, `subscriber.json`, and `sessions.json` +tracks (plus compressed `.json.z` twins) of cumulative counters per broadcast +and auth root, split by a **tier** label chosen by the auth server's grant or +`--cluster-tier`, which is what makes billing per customer or per region +possible. [Stats](/concept/stats) describes the paths, tracks, and encodings; +read them with the [`moq-stats`](https://docs.rs/moq-stats) crate. ## \[iroh] diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 22699656f8..c95fa3f2e4 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -104,7 +104,8 @@ document would silently discard everything but the last payload: The rest is descriptive: `compression` (`deflate`, the same group-scoped `deflate-raw` the catalog uses), `schema` on a JSON track, `mime` on a binary -one, plus the optional `broadcast` reference. A +one, `bitrate` and `jitter` with the same meaning as for media, plus the +optional `broadcast` reference. A consumer that doesn't recognize a `mode` or `compression` ignores that track and round-trips it verbatim. @@ -116,6 +117,12 @@ or `catalog.binary.tracks`, then pair its name and config with `moq_publish_binary_*` do the same, retracting on `_finish`. In the browser, read the same map, subscribe by name, and hand the track to `@moq/json` or `@moq/binary`. +An application with its own per-track fields can list a data track in its own +root section instead, flattening the JSON or binary entry beside those fields +so there is one entry per track. Name the section with a namespaced key such as +`com.example.mavlink`. A generic consumer only finds tracks in `json` and +`binary`. + ## Container The `container.kind` on each rendition says how frames are framed: diff --git a/doc/concept/index.md b/doc/concept/index.md index 5dbe3880bf..fae0751a11 100644 --- a/doc/concept/index.md +++ b/doc/concept/index.md @@ -37,5 +37,6 @@ watched at 100 ms by one viewer and 10 s by another. - [moq-lite](/concept/moq-lite): the pub/sub protocol, discovery, path patterns, subscriptions, and congestion behavior. - [hang](/concept/hang): the media catalog, containers, and how to extend both. - [Audio jitter](/concept/audio-jitter): how a receiver sizes its audio playout target from arrival timing. +- [Stats](/concept/stats): the traffic counters a relay publishes as broadcasts, and how to read them. - [Standards](/concept/standard): how this relates to the IETF moq-transport, MSF, LOC, and this project's own drafts, including [e2ee](/draft/moq-e2ee). - [Use cases](/concept/use-case/): MoQ compared with HLS/DASH, RTMP/SRT, WebRTC, and used for AI. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 2235506c52..33930649a2 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -29,8 +29,10 @@ implement in an afternoon. The wire spec is A dedicated ALPN selects the wire version for moq-lite 03 and newer. The legacy `moql` ALPN negotiates moq-lite 01 or 02 via `SETUP`. In moq-lite 05 and newer, each side also sends a `SETUP` message with its capabilities. -Rust and TypeScript speak moq-lite 01 through 07 and moq-transport drafts -14 through 22. Clients offer `moq-lite-07` first by default. +Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts +14 through 22. Clients offer `moq-lite-06` first by default. moq-lite 07 is +still in progress: it negotiates as `moq-lite-07-wip`, and only when both +sides explicitly enable it. ## Discovery @@ -85,7 +87,8 @@ let announced = origin.consume().with_hidden(true).announced(); const announced = connection.announced(Path.Pattern.all(), { hidden: true }); ``` -On the wire, moq-lite 07 carries the opt-in on each announce request, and +On the wire, moq-lite 07 (`moq-lite-07-wip`, opt-in only) carries the opt-in +on each announce request, and moq-transport carries it as a `SUBSCRIBE_NAMESPACE` parameter once the peer's `SETUP` says it understands one ([hidden](/draft/moq-hidden)). An older peer never opts in, so it never discovers hidden routes. Rust sessions always opt in diff --git a/doc/concept/stats.md b/doc/concept/stats.md new file mode 100644 index 0000000000..7b6cb321ca --- /dev/null +++ b/doc/concept/stats.md @@ -0,0 +1,161 @@ +--- +title: Stats +description: The stats broadcasts a relay publishes, their tracks, and both JSON encodings +--- + +# Stats + +A relay publishes its traffic counters as ordinary MoQ broadcasts, so any +subscriber can read them: a dashboard, a billing meter, an aggregator in +another region. This page is the wire contract for those broadcasts, enough to +read them in any language. [`moq-stats`](https://docs.rs/moq-stats) is the Rust +producer and consumer, and the relay's [`[stats]`](/bin/relay/config#stats) +section turns it on. + +## Broadcasts + +Each node publishes under a prefix, `.stats` by default, which +[moq-lite](/concept/moq-lite) hides from announce listings that don't ask for +it. Traffic under the prefix is never counted, so serving stats doesn't +generate more stats. + +```text +/node/ depth 0: one broadcast per node +//node/ depth N: one broadcast per group per node +``` + +- `` tells relays sharing a cluster apart. It may span several segments + (`sjc/1`), and is omitted along with its slash when unset: `/node`. +- `` is the first `depth` segments of each broadcast path (for traffic) + or auth root (for sessions), so a consumer can scope an announce to one + tenant. A path shorter than `depth` groups under all of its segments. +- The literal `node` segment leaves room for sibling categories under the same + prefix, so a consumer skips any path without `node` where it expects one. A + group segment literally named `node` is ambiguous; don't use one. + +At depth 0 the broadcast stays announced for the producer's life. At depth +1 or more, a group's broadcast is announced while that group has entries and +unannounced once it has none. + +## Tracks + +Traffic is split by **tier**, an arbitrary label (a billing class, a region) +the relay takes from the auth grant or `--cluster-tier`. Each tier has three +tracks, each in two encodings: + +| Track | Frame keyed by | Entry | +| --- | --- | --- | +| `publisher.json` | broadcast path | [Traffic](#traffic) this node sent (egress) | +| `subscriber.json` | broadcast path | [Traffic](#traffic) this node received (ingress) | +| `sessions.json` | auth root | [Presence](#presence) of connected sessions | + +The default tier is unprefixed. A named tier prefixes each name with its +label and a slash: tier `region/sjc` publishes `region/sjc/publisher.json`. +Appending `.z` selects the [compressed](#compressed) encoding of the same +track: `publisher.json.z`. + +The default tier's six tracks always exist. A named tier's are created on its +first recorded traffic, but a subscriber may ask for them earlier: any name of +the shape `[/]{publisher,subscriber,sessions}.json[.z]` is accepted and +held open with `{}` until the tier records. Any other name is refused. + +## Frames + +Every frame is a JSON object mapping a key (broadcast path or auth root) to an +entry. An entry appears while it is **live**, meaning some started counter +still exceeds its ended counterpart so traffic could resume at any moment, and +on any tick its counters changed. Once fully closed it appears one last time +with its final counters and is then dropped. A track with no entries holds `{}`. + +The producer drains its counters every interval (one second by default) and +writes only when a track's frame changed, so silence means nothing moved, not +that the producer is gone. The track ends when the producer does, or at +depth 1 or more when its group's broadcast is unannounced; the group may +return later as a new broadcast. + +### Traffic + +```json +{ + "acme/live": { + "announces_started": 1, "announces_ended": 0, "announced_bytes": 9, + "broadcasts_started": 3, "broadcasts_ended": 1, + "subscriptions_started": 6, "subscriptions_ended": 2, + "fetches": 0, + "bytes": 1048576, "frames": 900, "groups": 30, "datagrams": 0, + "stale": { "bytes": 0, "frames": 0, "groups": 0, "datagrams": 0 }, + "announced": 1, "announced_closed": 0, + "broadcasts": 3, "broadcasts_closed": 1, + "subscriptions": 6, "subscriptions_closed": 2 + } +} +``` + +| Field | Counts | +| --- | --- | +| `announces_started` / `announces_ended` | Announces and unannounces of the broadcast. | +| `announced_bytes` | The broadcast name's length, summed over each announce and unannounce. Not part of `bytes`. | +| `broadcasts_started` / `broadcasts_ended` | On `publisher.json`, a session's first subscription to the broadcast and its last one closing. Started minus ended is the viewer count. `subscriber.json` leaves both at zero: ingress does not count viewers. | +| `subscriptions_started` / `subscriptions_ended` | Track subscriptions opened and closed. | +| `fetches` | One-shot group fetches requested, including ones that found nothing. Their payload counts in `bytes`, `frames`, and `groups`. | +| `bytes` / `frames` / `groups` | Payload delivered. | +| `datagrams` | Groups delivered as an unreliable datagram. A subset of `groups`. | +| `stale` | Payload skipped because it aged past a subscriber's latency budget, with the same four fields. Disjoint from the top-level payload counters. | + +The last six fields are legacy spellings of the `*_started` and `*_ended` +counters, still written so an older consumer reads a newer relay. A reader +should prefer the canonical name and fall back to the legacy one. + +### Presence + +```json +{ "acme": { "sessions_started": 12, "sessions_ended": 10, "sessions": 12, "sessions_closed": 10 } } +``` + +`sessions_started` and `sessions_ended` count connects and disconnects under an +auth root on the tier, whether or not any data flows. `sessions` and +`sessions_closed` are their legacy spellings. A session moved to a new tier +ends on the old one and starts on the new. + +### Counters + +Every counter is a cumulative, monotonic unsigned integer. A rate is the +difference between two frames divided by the time between them, and a live +count is started minus ended. A frame never shows ended above started. + +A counter going **down** means the relay restarted or the entry was dropped +and re-created. Treat it as the start of a fresh segment rather than a +negative rate. + +A reader ignores unknown fields, so a newer relay can add counters, and +defaults a missing field to zero, so it can read an older relay. + +## Encodings + +Both encodings carry identical frames; pick by bandwidth. Only the track name +says which one a track uses: the payload has no marker. + +### Plain + +On a `.json` track each changed frame is its own group holding one frame, the +full object as UTF-8 JSON. A reader takes the newest group. + +### Compressed + +A `.json.z` track is a [moq-json](/lib/rs/moq-json) snapshot track with +compression on. Stats frames change little between ticks, so it costs a +fraction of the plain track's bytes. + +- **Groups.** A group's first frame is the full object. Each later frame is an + [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396) merge patch against the + value so far: it carries only the changed counters, and `null` removes a + dropped entry. The producer starts a new group once the patches outgrow + eight times the snapshot's compressed size, or after 256 frames. +- **DEFLATE.** Each group's frames form one raw DEFLATE stream, sync flushed + per frame with the trailing `00 00 ff ff` stripped, as + [moq-flate](/draft/moq-flate) specifies. The window starts cold at every + group and never spans two. + +To read one, jump to the newest group, inflate and parse its first frame, +then inflate and apply each following frame as a merge patch, in order. A +reader missing a frame abandons the group and waits for the next. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index a32dcc4aca..17b532505f 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -41,7 +41,7 @@ and `target/include/moq.h`. - **Encoded video metadata.** `moq_video_init.hint` is a zero-initialized `moq_video_hint` with `has_*` flags for coded dimensions, bitrate (bits per second), frame rate, and latency preference. Hints seed a video codec track's catalog; detected dimensions take precedence. - **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, the Happy Eyeballs delays, `websocket_enabled`/`_delay_us`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. -- **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. +- **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge, so a cache linger never delays it. Only a relay keeps what it already delivered warm for 30 seconds, and a returning subscriber is served that cache only once the publisher confirms it is still current. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. - **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 3b6b3c693a..f086c11cd9 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -52,6 +52,7 @@ for (;;) { - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. +- **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/rs/moq-audio.md b/doc/lib/rs/moq-audio.md index 82f65a3306..e218553075 100644 --- a/doc/lib/rs/moq-audio.md +++ b/doc/lib/rs/moq-audio.md @@ -27,6 +27,14 @@ policy. Decoding likewise separates low-level `decode::Config`, PCM | `playback` | One output device mixing every track in a call, with click-free volume ramps | | `aec` | Acoustic echo cancellation (a port of WebRTC's), so a laptop with no headset doesn't feed itself back | +Opus always decodes at 48 kHz, its own clock, whatever input rate the OpusHead +records (44.1 kHz and unknown included), and applies the head's pre-skip and +output gain. A track without a description decodes mono or stereo from the +catalog with neither. A description that is present but malformed (truncated, +wrong signature, a new major version, or a channel count its mapping family +forbids) is refused, as is any channel mapping family other than 0, rather +than falling back to the catalog's fields. + Highlights: - **`encode::Publication`** advertises the track and opens the microphone only while someone listens. Stop, swap devices, and restart without changing the track subscribers know; read a level meter for the UI. diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index 71ac4c051f..9f2496c098 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -18,7 +18,7 @@ accept loop that decides in process. - **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. - **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; a token carrying the retired `put` and `get` prefix lists fails verification. +- **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. ```bash diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 613f9e2b52..5fe596a4fd 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -47,6 +47,47 @@ before the edit is retained, including while the initial catalog is reserved. Codec importers propagate catalog and media errors through their configuration and frame-writing methods. +Data tracks go through the catalog too. `catalog.json_stream(track, config)` +(or `json_snapshot`, `binary_snapshot`, `binary_stream`) writes the track's +`json` or `binary` entry, measures an absent `bitrate` from the writes, and +retires the entry when the producer drops. To list the track in your own +section beside application fields, pass that section's entry instead of a +`json::Config` or `binary::Config`: any `RenditionConfig` that embeds the data +config through `AsMut`. + +```rust +#[derive(Serialize, Deserialize, Clone)] +struct Mavlink { + #[serde(flatten)] + binary: hang::catalog::BinaryConfig, // mode, compression, bitrate, ... + sysid: u8, +} + +impl AsMut for Mavlink { + fn as_mut(&mut self) -> &mut hang::catalog::BinaryConfig { + &mut self.binary + } +} + +// Plus `RenditionConfig` writing to `catalog.ext.mavlink`, a map +// serialized under the `com.example.mavlink` root key. +let binary = hang::catalog::BinaryConfig::new(hang::catalog::Mode::Stream); +let mut telemetry = catalog.binary_stream(track, Mavlink { binary, sysid: 1 })?; +telemetry.append(packet)?; +``` + +The producer sets the entry's `mode` and encodes the track with its +`compression`. Read it back from `Catalog` and subscribe with +`catalog::Entry::new(name, &entry.binary)`. + +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 +mapping, and a source that restarts its timestamps continues forward after the +real idle gap. fMP4 passthrough rewrites each fragment's `tfdt` to match. Use +it for a live feed with its own zero; publish verbatim only when the catalog's +clock (`Config::with_clock`) already names the source's zero. + ```bash cargo add moq-mux ``` diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 2f56c399b6..5f6cb60673 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -20,6 +20,7 @@ above ([hang](/lib/rs/hang)); relays and CDNs implement only this. - **Patterns** (`Pattern`, `Patterns`) are re-exported from [`moq-pattern`](https://docs.rs/moq-pattern). Literal `Path` stays a coordinate. - **Tracks** carry groups with a priority, a retention window, and a timescale. Subscribers set their own priority and max age and can change them live. - **Groups** are written frame by frame and delivered on independent streams. Old groups are cached for fetch-by-sequence; stale groups are skipped per the subscriber's budget. +- **Track ends**: `finish()` ends a track at its live edge, while `finish_at(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber awaits it with `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** send a single small frame unreliably on moq-lite 05+. - **Routes** record the relay hops and a cost, which is what the relay [cluster](/bin/relay/cluster) routes on. A hop of 0 marks the chain anonymous: `Route::is_anonymous()` is true, and that route ranks below every fully identified one. `Route::source()` says where a delivered route entered: `Source::Local`, or `Source::Peer(hop)` when a handle marked `origin::Producer::peer()` announced it. `origin::Consumer::local()` sees only the local ones. - **Stats** counters per broadcast and session, drained by [`moq-stats`](https://docs.rs/moq-stats). diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index a12225da3e..99013372b3 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -18,7 +18,7 @@ error lists and rerun. ## Wire Older protocol versions still negotiate, so relays and clients can be upgraded -in any order, apart from [re-minting tokens](#relay-and-cli) and two wire +in any order, apart from [pattern-only token grants](#relay-and-cli) and two wire changes: - The lite 06 ALPN is `moq-lite-06`, not `moq-lite-06-wip`. An explicit @@ -66,10 +66,14 @@ Other changes to a deployment: `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`. -- **Re-mint tokens.** JWT `publish` and `subscribe` claims are patterns, so a - token granting `alice` covers only `alice`; sign `alice/**` instead. Tokens - carrying the retired `put` or `get` claims fail verification, so re-mint - them when the relay and auth server upgrade. +- **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, + and subtree-only grants are still signed in that form, so a `moq-token` + deployment can upgrade issuers and verifiers in either order. Verifiers on + 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. - **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. @@ -133,7 +137,7 @@ The JavaScript packages have no changelog; this list follows the breaking PRs, so a minor rename may be missing. - **@moq/token is @moq/auth.** `sign` / `verify` are `Key.sign` / `Key.verify`, - and claims are pattern unions (see [Re-mint tokens](#relay-and-cli)). + and claims are pattern unions (see [Token grants are patterns](#relay-and-cli)). - **One `Connection`** (#3614, #3636). `Connection.Reload` is `new Moq.Connection({ url })`, which pools one connection per relay. `closed` settles only on `close()`; the error that stopped retrying is `error`. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index ed250cf3fa..30d711e3c9 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -134,6 +134,7 @@ type Catalog = { ~~~ Additional fields MAY be added based on the application. +An application SHOULD name its own root sections with a namespaced key, such as a reverse-DNS name (`com.example.telemetry`), so they cannot collide with a section a later version of this specification defines. The catalog SHOULD be mostly static, delegating any dynamic content to other tracks. For example, a chat entry should name a chat track, not carry individual chat messages. @@ -388,6 +389,8 @@ type JsonSchema = { "compression": Compression | undefined, "schema": string | undefined, "broadcast": string | undefined, + "bitrate": number | undefined, + "jitter": number | undefined, } ~~~ @@ -401,6 +404,8 @@ type BinarySchema = { "compression": Compression | undefined, "mime": string | undefined, "broadcast": string | undefined, + "bitrate": number | undefined, + "jitter": number | undefined, } ~~~ @@ -456,6 +461,10 @@ A `snapshot` group covers a single value (plus any deltas), so its window spans ### broadcast {#data-shared} The `broadcast` field carries the same meaning here as it does for a media rendition ({{field-broadcast}}). +### bitrate and jitter {#data-estimates} +The optional `bitrate` field is the track's maximum bitrate in bits per second. +The optional `jitter` field carries the same meaning and rules as it does for a media rendition ({{field-jitter}}), with a payload in place of a frame. + ## Binary Fields {#binary} A decoder config field carrying raw bytes, notably `description` (an `AllowSharedBufferSource` in WebCodecs), is carried in the catalog as a hex string ({{!RFC4648, Section 8}}). A publisher SHOULD emit lowercase hexadecimal characters and MUST NOT emit a `0x` prefix or any separators. @@ -1075,6 +1084,8 @@ A publisher MAY estimate an unknown final duration from the frame cadence, but M - A publisher that stops producing and may resume on the same track SHOULD publish a discontinuity marker when it stops. - An audio endpoint bounds only the terminal packets that follow it in its own group. - Replaced the archive timeline `wall` field with a root `clock` section (`wall` plus `timescale`): one fixed broadcast mapping every track and the archive index convert into, independent of any archive. Zero timescales and walls past the JSON-safe integer range are refused. +- Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. +- Recommended namespaced keys for application root sections. # Acknowledgments {:numbered="false"} diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 8c5c464c08..679b2056d6 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -94,7 +94,7 @@ A Session consists of a connection between a client and a server. There is currently no P2P support within QUIC so it's out of scope for moq-lite. The moq-lite version identifier is `moq-lite-xx` where `xx` is the two-digit draft version. -The identifier for this draft is `moq-lite-07`. +The identifier for this draft is `moq-lite-07-wip` while it is a work in progress, and becomes `moq-lite-07` once finalized. For bare QUIC, this is negotiated as an ALPN token during the QUIC handshake. For WebTransport over HTTP/3, the QUIC ALPN remains `h3` and the moq-lite version is advertised via the `WT-Available-Protocols` and `WT-Protocol` CONNECT headers. @@ -447,7 +447,7 @@ A receiver SHOULD NOT cache refusals; rate limiting is the advertiser's concern. A subscriber opens Subscribe Streams to request a Track. The subscriber MUST start a Subscribe Stream with a SUBSCRIBE message followed by any number of SUBSCRIBE_UPDATE messages. -The publisher replies with a SUBSCRIBE_OK message once the start group is resolved, followed by any number of SUBSCRIBE_END and SUBSCRIBE_DROP messages. +The publisher replies with a SUBSCRIBE_OK message once the start group is resolved, followed by a SUBSCRIBE_END message once the subscription ends. For a live track the publisher MAY withhold SUBSCRIBE_OK until the first matching group resolves the start; if the track has already ended with no matching groups, it sends SUBSCRIBE_END with no preceding SUBSCRIBE_OK. A rejection is a stream reset: a publisher that cannot serve the subscription (no such track, an ended broadcast, or any other refusal) MUST promptly reset the stream rather than leave it pending, so a subscriber distinguishes "pending" from "refused" by the reset, not by a timeout. A route claims capability rather than inventory, so a subscription for a covered path that names nothing is refused this way too. @@ -455,9 +455,8 @@ A route claims capability rather than inventory, so a subscription for a covered The track's immutable publisher properties are not carried here; they are fetched once via a [Track Stream](#track-stream). The subscriber needs the track's TRACK_INFO (notably its timescale) to interpret FRAME messages, and MAY open the Track and Subscribe streams concurrently, buffering frames until it arrives. -The publisher sends SUBSCRIBE_OK once the absolute start position is resolved, and SUBSCRIBE_END once no further groups will be produced (see [SUBSCRIBE_OK](#subscribe-ok) and [SUBSCRIBE_END](#subscribe-end)). -The publisher closes the stream (FIN) only once every group from start to end has been accounted for, either via a Group Stream (completed or reset) or a SUBSCRIBE_DROP message. -This MAY occur after SUBSCRIBE_END, since stragglers within the range can still be dropped. +The publisher sends SUBSCRIBE_OK once the absolute start position is resolved, and SUBSCRIBE_END once no further groups will be produced and every Group Stream it opens for the subscription has been opened (see [SUBSCRIBE_OK](#subscribe-ok) and [SUBSCRIBE_END](#subscribe-end)). +The publisher closes the stream (FIN) after SUBSCRIBE_END, once every counted Group Stream has finished or been reset. Unbounded subscriptions stay open until SUBSCRIBE_END, and either endpoint MAY reset the stream at any time. ### Fetch @@ -1116,7 +1115,7 @@ Set to 0x0 to indicate a SUBSCRIBE_OK message. **Group**: The absolute sequence number of the first group that will be delivered. -It MUST be greater than or equal to the requested start group; any groups in between are unavailable and implicitly dropped, with no separate SUBSCRIBE_DROP required. +It MUST be greater than or equal to the requested start group; any groups in between are unavailable. A subscriber that requested the latest group learns the resolved sequence here. There is no matching frame field, because the start frame is never in doubt: a partial group is only delivered when it was asked for, so the subscription starts either exactly where it asked or at the beginning of a later group (see [Positions](#positions)). @@ -1136,6 +1135,7 @@ SUBSCRIBE_END Message { Type (i) = 0x1 Message Length (i) Group (i) + Stream Count (i) } ~~~ @@ -1147,34 +1147,14 @@ The exclusive end of the range: the absolute sequence number of the first group A value of 0 means the track ended before producing any groups. The subscriber MUST NOT wait for any group at or after this sequence. -SUBSCRIBE_END bounds the range but does not by itself end the stream: the publisher MAY still send SUBSCRIBE_DROP for groups below this sequence that it cannot deliver, and FINs the stream only once every group below this sequence has been accounted for. +**Stream Count**: +The number of Group Streams the publisher opened for this subscription, whether they finished or were reset. +A group that was skipped, never produced, delivered only as a datagram, or given up before its stream opened is not counted. +A relay counts the Group Streams it opened itself, never the count it received upstream. -## SUBSCRIBE_DROP -A SUBSCRIBE_DROP message is sent by the publisher on the Subscribe Stream when groups cannot be served. -It MAY arrive at any point after the subscription is opened, including after SUBSCRIBE_END for stragglers within the resolved range (a leading range is instead dropped implicitly by SUBSCRIBE_OK). - -~~~ -SUBSCRIBE_DROP Message { - Type (i) = 0x2 - Message Length (i) - Group Start (i) - Group End (i) - Error Code (i) -} -~~~ - -**Type**: -Set to 0x2 to indicate a SUBSCRIBE_DROP message. - -**Group Start**: -The first absolute group sequence in the dropped range. - -**Group End**: -The last absolute group sequence in the dropped range (inclusive). - -**Error Code**: -An application-specific error code. -A value of 0 indicates no error; the groups are simply unavailable. +The publisher MUST NOT send SUBSCRIBE_END until every Group Stream it will open for the subscription has been opened, so the count is final; it does not wait for them to finish. +The subscriber has received every Group Stream once it has read the header of `Stream Count` of them, which MAY happen after SUBSCRIBE_END or the FIN since streams are not ordered. +A Group Stream reset before its header arrived is never seen, so a subscriber SHOULD bound how long it waits for the rest, for example by `Subscriber Max Age`. ## FETCH FETCH is sent by a subscriber to request a single group from a track. @@ -1330,8 +1310,11 @@ The `Message Length` describes the payload size on the wire. ## moq-lite-07 -- Assigned `moq-lite-07` as this draft's protocol identifier. +- Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. - Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. +- Added `Stream Count` to SUBSCRIBE_END: the number of Group Streams opened for the subscription. SUBSCRIBE_END is now sent once every counted Group Stream has opened, rather than as soon as the final group is known. +- Removed SUBSCRIBE_DROP and its type 0x2; a group without a Group Stream is not counted. +- The Subscribe Stream FIN now follows once every counted Group Stream has finished or been reset. ## moq-lite-06 diff --git a/js/auth/src/claims.test.ts b/js/auth/src/claims.test.ts index f5114f454b..f55daead33 100644 --- a/js/auth/src/claims.test.ts +++ b/js/auth/src/claims.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { authorize, type Claims, ClaimsSchema, ScopeSchema } from "./claims.ts"; +import { encodeGrants } from "./wire.ts"; // These cases mirror the Rust moq-auth crate's claims::tests one-for-one, so both // sides stay pinned to the same authorization semantics. @@ -23,11 +24,35 @@ test("claims granting nothing are rejected, matching Rust's useless-token rule", expect(ClaimsSchema.parse({ root: "demo", publish: ["**"] }).publish).toEqual(["**"]); }); -test("claims refuse the retired put/get prefix fields", () => { - expect(() => ClaimsSchema.parse({ root: "demo", put: ["alice"] })).toThrow(); - expect(() => ClaimsSchema.parse({ root: "demo", get: "" })).toThrow(); +test("claims read legacy put/get prefixes as subtrees", () => { + const claims = ClaimsSchema.parse({ root: "demo", put: ["alice", "/a//b/"], get: "" }); + expect(claims.publish).toEqual(["alice/**", "a/b/**"]); + expect(claims.subscribe).toEqual(["**"]); + expect(ScopeSchema.parse({ root: "demo", put: ["room"] }).publish).toEqual(["room/**"]); +}); + +test("claims refuse mixed encodings and wildcard legacy prefixes", () => { expect(() => ClaimsSchema.parse({ root: "demo", publish: ["alice"], get: [""] })).toThrow(); - expect(() => ScopeSchema.parse({ root: "demo", put: ["alice"] })).toThrow(); + expect(() => ClaimsSchema.parse({ root: "demo", put: [], subscribe: ["alice"] })).toThrow(); + expect(() => ClaimsSchema.parse({ root: "demo", put: ["a/*"] })).toThrow(); + expect(() => ScopeSchema.parse({ root: "demo", put: ["room"], publish: ["x"] })).toThrow(); + // A legacy scope held only lists. + expect(() => ScopeSchema.parse({ root: "demo", put: "room" })).toThrow(); +}); + +test("grants are written the legacy way only when faithful", () => { + expect(encodeGrants({ root: "live", publish: ["camera1/**"], subscribe: ["**"] })).toEqual({ + root: "live", + put: ["camera1"], + get: [""], + }); + // One grant a prefix can't say moves the whole document to patterns. + expect(encodeGrants({ root: "live", publish: ["camera1/**"], subscribe: ["*/chat"] })).toEqual({ + root: "live", + publish: ["camera1/**"], + subscribe: ["*/chat"], + }); + expect(encodeGrants({ root: "live", publish: ["camera1"] })).toEqual({ root: "live", publish: ["camera1"] }); }); test("claims exp and iat are whole seconds", () => { diff --git a/js/auth/src/claims.ts b/js/auth/src/claims.ts index 1deb99c9e9..493690b947 100644 --- a/js/auth/src/claims.ts +++ b/js/auth/src/claims.ts @@ -7,6 +7,7 @@ import { Pattern, Patterns } from "@moq/pattern"; import * as z from "@zod/mini"; import * as Path from "./path.ts"; +import { decodeGrants } from "./wire.ts"; /** A list of pattern texts, each validated by `Pattern.parse`. */ export const PatternListSchema = z.array( @@ -30,29 +31,48 @@ export function patterns(texts: readonly string[] | undefined): Patterns { return new Patterns((texts ?? []).map((text) => Pattern.parse(text))); } +const ScopeFields = { + /** The root that `publish` and `subscribe` are relative to. Defaults to the empty string. */ + root: z._default(z.string(), ""), + /** Patterns this key may grant to publishers, relative to `root`. */ + publish: z.optional(PatternListSchema), + /** Patterns this key may grant to subscribers, relative to `root`. */ + subscribe: z.optional(PatternListSchema), +}; + /** * The immutable ceiling on what a key may grant, embedded in its JWK. * * `root` is optional on the wire to match the Rust `moq-auth` crate, which omits it - * when the scope sits at the top level. Any other field, including the retired `put` - * and `get` prefix lists, is refused. + * when the scope sits at the top level. A legacy `put`/`get` prefix scope reads as the + * subtree patterns it meant. Any other field is refused. */ export const ScopeSchema = z - .strictObject({ - /** The root that `publish` and `subscribe` are relative to. Defaults to the empty string. */ - root: z._default(z.string(), ""), - /** Patterns this key may grant to publishers, relative to `root`. */ - publish: z.optional(PatternListSchema), - /** Patterns this key may grant to subscribers, relative to `root`. */ - subscribe: z.optional(PatternListSchema), - }) + .pipe( + z.strictObject({ + ...ScopeFields, + put: z.optional(z.array(z.string())), + get: z.optional(z.array(z.string())), + }), + z.transform(decodeGrants), + ) .check( z.refine((data) => (data.publish?.length ?? 0) > 0 || (data.subscribe?.length ?? 0) > 0, { message: "Either publish or subscribe must contain at least one pattern", }), ); -export type Scope = z.infer; +export type Scope = z.output; + +const ClaimsFields = { + ...ScopeFields, + /** Expiration time, as a whole unix timestamp in seconds. */ + exp: z.optional(z.int()), + /** Issued-at time, as a whole unix timestamp in seconds. */ + iat: z.optional(z.int()), +}; + +const PrefixListSchema = z.union([z.string(), z.array(z.string())]); /** * The JWT claims structure for moq-auth. @@ -60,22 +80,15 @@ export type Scope = z.infer; * `root` is optional on the wire: a token scoped to the top-level path omits it, so * it defaults to the empty string to match the Rust `moq-auth` crate. A pattern names * exactly what it says: `alice` is one broadcast, `alice/**` is a subtree, and `**` is - * everything under the root. Any other field, including the retired `put` and `get` - * prefix lists, fails verification. + * 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. */ export const ClaimsSchema = z - .strictObject({ - /** The root that `publish` and `subscribe` are relative to. Defaults to the empty string. */ - root: z._default(z.string(), ""), - /** Patterns the holder may publish to, relative to `root`. */ - publish: z.optional(PatternListSchema), - /** Patterns the holder may subscribe to, relative to `root`. */ - subscribe: z.optional(PatternListSchema), - /** Expiration time, as a whole unix timestamp in seconds. */ - exp: z.optional(z.int()), - /** Issued-at time, as a whole unix timestamp in seconds. */ - iat: z.optional(z.int()), - }) + .pipe( + z.strictObject({ ...ClaimsFields, put: z.optional(PrefixListSchema), get: z.optional(PrefixListSchema) }), + z.transform(decodeGrants), + ) .check( // Emptiness, not just presence: `publish: []` grants nothing, and the Rust crate // rejects such a token as useless. Checking `!== undefined` here would mint @@ -88,7 +101,7 @@ export const ClaimsSchema = z /** * JWT claims structure for moq-auth */ -export type Claims = z.infer; +export type Claims = z.output; /** * The access a {@link Claims} grants at a specific path, with every pattern rebased so diff --git a/js/auth/src/cli.ts b/js/auth/src/cli.ts index 07234f6694..43c0e0b52a 100755 --- a/js/auth/src/cli.ts +++ b/js/auth/src/cli.ts @@ -6,6 +6,7 @@ import { Command, Option } from "commander"; import type { Algorithm } from "./algorithm.ts"; import { authorize, type Claims, type Scope, ScopeSchema } from "./claims.ts"; import { Key } from "./key.ts"; +import { encodeGrants } from "./wire.ts"; const program = new Command(); @@ -37,8 +38,9 @@ program key = { ...key, scope }; } - const encodeKey = (k: object): string => { - const json = JSON.stringify(k, null, 2); + const encodeKey = (k: { scope?: Scope }): string => { + // Written the legacy way when that says the same thing, so older readers load it. + const json = JSON.stringify(k.scope ? { ...k, scope: encodeGrants(k.scope) } : k, null, 2); if (options.base64) { return base64.fromArrayBuffer(new TextEncoder().encode(json).buffer, true); } diff --git a/js/auth/src/key.test.ts b/js/auth/src/key.test.ts index 542cab7ffa..6224ced2ab 100644 --- a/js/auth/src/key.test.ts +++ b/js/auth/src/key.test.ts @@ -713,7 +713,7 @@ test("verify - claims validation during verification", async () => { expect(verifiedClaims.root).toBe("test-path"); }); -test("verify - a token carrying the retired put/get prefix fields is refused", async () => { +test("verify - a legacy put/get prefix token reads as subtrees", async () => { const key = Key.parse(encodeJwk(testKey)); const secret = await crypto.subtle.importKey( "raw", @@ -726,7 +726,25 @@ test("verify - a token carrying the retired put/get prefix fields is refused", a const legacy = await new SignJWT({ root: "test-path", put: ["alice"], get: [""] }) .setProtectedHeader({ alg: "HS256", kid: testKey.kid }) .sign(secret); - await expect(Key.verify(key, legacy)).rejects.toThrow(/put|Unrecognized/); + const claims = await Key.verify(key, legacy); + expect(claims.publish).toEqual(["alice/**"]); + expect(claims.subscribe).toEqual(["**"]); +}); + +test("sign - subtree grants are written as legacy put/get", async () => { + const key = Key.parse(encodeJwk(testKey)); + const token = await Key.sign(key, { root: "test-path", publish: ["alice/**"], subscribe: ["**"] }); + const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()); + expect(payload).toEqual({ root: "test-path", put: ["alice"], get: [""] }); +}); + +test("sign - legacy-shaped input cannot slip past a key scope", async () => { + const scoped: Key = { + ...Key.parse(encodeJwk(testKey)), + scope: { root: "demo", publish: ["inside/**"] }, + }; + const legacy = { root: "demo", put: ["outside"] } as unknown as Parameters[1]; + await expect(Key.sign(scoped, legacy)).rejects.toThrow(/scope/); }); test("key scope is enforced when signing and verifying", async () => { diff --git a/js/auth/src/key.ts b/js/auth/src/key.ts index 4b6f9cd068..17b29bf3ea 100644 --- a/js/auth/src/key.ts +++ b/js/auth/src/key.ts @@ -3,6 +3,7 @@ import * as z from "@zod/mini"; import * as jose from "jose"; import { type Algorithm, AlgorithmSchema } from "./algorithm.ts"; import { type Claims, ClaimsSchema, ScopeSchema, scopeAllows } from "./claims.ts"; +import { encodeGrants } from "./wire.ts"; /** * A validated key identifier (kid). Only alphanumeric, hyphens, and underscores. @@ -185,16 +186,19 @@ function parse(jwk: string): Key { async function sign(key: Key, claims: Claims): Promise { ensureOperationSupported(key, "sign"); - // Validate claims before signing + // Scope-check and sign what the schema parsed, never the raw input: an untyped + // caller could pass legacy `put`/`get` fields the scope check would not see. + let parsed: Claims; try { - ClaimsSchema.parse(claims); + parsed = ClaimsSchema.parse(claims); } catch (error) { throw new Error(`Invalid claims: ${error instanceof Error ? error.message : "unknown error"}`); } - ensureClaimsWithinScope(key, claims); + ensureClaimsWithinScope(key, parsed); const joseKey = await importJoseKey(key); - const jwt = await new jose.SignJWT(claims) + // Written the legacy way when that says the same thing, so older verifiers accept it. + const jwt = await new jose.SignJWT(encodeGrants(parsed)) .setProtectedHeader({ alg: key.alg, typ: "JWT", diff --git a/js/auth/src/wire.ts b/js/auth/src/wire.ts new file mode 100644 index 0000000000..798f840565 --- /dev/null +++ b/js/auth/src/wire.ts @@ -0,0 +1,83 @@ +/** + * The JSON encoding of the grants in claims and key scopes. + * + * Grants were once prefix lists named `put` and `get`, and every published `moq-token` + * reader still expects them. Must stay in lockstep with `wire.rs` in the Rust + * `moq-auth` crate. + * + * @module + */ + +import { Pattern, Patterns } from "@moq/pattern"; + +/** One grant in whichever encoding it arrived: legacy `put`/`get` prefix lists, or patterns. */ +type WireGrants = { + put?: string | string[]; + get?: string | string[]; + publish?: string[]; + subscribe?: string[]; +}; + +/** + * Read legacy `moq-token` prefix grants as the subtree patterns they always meant. + * + * A prefix `p` is exactly `p/**` (and `""` is `**`). A document never mixes the two + * encodings, and a legacy prefix containing `*` is refused: it had no wildcards, so + * reading one now would silently widen the grant. + */ +export function decodeGrants( + wire: T, + ctx: { issues: unknown[] }, +): Omit & { publish?: string[]; subscribe?: string[] } { + const { put, get, ...rest } = wire; + if (put === undefined && get === undefined) return rest; + + const fail = (message: string) => { + ctx.issues.push({ code: "custom", message, input: wire }); + return rest; + }; + if (rest.publish !== undefined || rest.subscribe !== undefined) { + return fail("mixes the legacy put/get fields with publish/subscribe"); + } + + const subtrees = (prefixes: string | string[] | undefined) => + prefixes === undefined + ? undefined + : (typeof prefixes === "string" ? [prefixes] : prefixes).map((prefix) => Pattern.subtree(prefix).text); + try { + return { ...rest, publish: subtrees(put), subscribe: subtrees(get) }; + } catch (error) { + return fail(`legacy prefix: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Write grants the legacy way when every pattern is a subtree, so every published + * `moq-token` reader agrees on what they grant. Anything a prefix can't say is written + * as `publish`/`subscribe`, which an older reader refuses rather than misreads. + */ +export function encodeGrants( + grants: T, +): Omit & WireGrants { + const { publish, subscribe, ...rest } = grants; + const prefixes = (texts: string[] | undefined) => { + const out: string[] = []; + for (const pattern of new Patterns((texts ?? []).map((text) => Pattern.parse(text)))) { + const prefix = pattern.asPrefix(); + if (prefix === undefined) return undefined; + out.push(prefix); + } + return out; + }; + + const put = prefixes(publish); + const get = prefixes(subscribe); + if (put === undefined || get === undefined) { + return { + ...rest, + ...(publish?.length && { publish }), + ...(subscribe?.length && { subscribe }), + }; + } + return { ...rest, ...(put.length && { put }), ...(get.length && { get }) }; +} diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index b8745147fb..4b21ace239 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -1,5 +1,4 @@ -import { heapStats } from "bun:jsc"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { DEFAULT_MAX_FRAME_SIZE } from "@moq/flate"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -148,6 +147,24 @@ test("an undecodable payload ends the log for a reader already inside the group" await expect(consumer.next()).rejects.toThrow("limit"); }); +// Counts the reactions `run` attaches to promises still pending once it returns. A promise holds each +// reaction until it settles, so one left per iteration on a promise that outlives the loop is a leak. +// Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + // A blocked read races the frame against the track's next group, which stays pending for the whole // log. Racing it per payload must not leave a reaction behind on it each time. test("blocked reads leave nothing behind on the pending group read", async () => { @@ -155,23 +172,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => const producer = new Producer({ track }); const subscriber = track.subscribe(); const consumer = new Consumer({ track: subscriber }); - const promises = () => { - Bun.gc(true); - return heapStats().objectTypeCounts.Promise ?? 0; - }; - const read = async (from: number, count: number) => { - for (let n = from; n < from + count; n++) { + const reactions = await pendingReactions(async () => { + for (let n = 0; n < 1000; n++) { const next = consumer.next(); producer.append(new Uint8Array([n & 0xff])); expect((await next)?.[0]).toBe(n & 0xff); } - }; - - await read(0, 50); - const before = promises(); - await read(50, 1000); - expect(promises() - before).toBeLessThan(100); + }); + expect(reactions).toBeLessThan(10); subscriber.close(); producer.finish(); diff --git a/js/hang/src/catalog/binary.ts b/js/hang/src/catalog/binary.ts index 2c209e1413..593eaf24d5 100644 --- a/js/hang/src/catalog/binary.ts +++ b/js/hang/src/catalog/binary.ts @@ -1,5 +1,6 @@ import * as z from "zod/mini"; import { CompressionSchema } from "./compression"; +import { u53Schema } from "./integers"; import { ModeSchema } from "./mode"; import { RelativeBroadcastSchema } from "./path"; @@ -28,6 +29,18 @@ export const BinaryConfigSchema = z.looseObject({ // An optional media type for each payload (e.g. "image/jpeg"). Purely descriptive: // a consumer that doesn't recognize it can still read the track. mime: z.optional(z.string()), + + // The maximum bitrate of the track in bits per second, if known. + bitrate: z.optional(u53Schema), + + // The maximum delay between a payload being ready and the publisher flushing it, in whole + // milliseconds rounded up, with the same meaning as a video rendition's `jitter`. + jitter: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/hang/src/catalog/json.ts b/js/hang/src/catalog/json.ts index 6ee22e78a0..c5900559d7 100644 --- a/js/hang/src/catalog/json.ts +++ b/js/hang/src/catalog/json.ts @@ -1,5 +1,6 @@ import * as z from "zod/mini"; import { CompressionSchema } from "./compression"; +import { u53Schema } from "./integers"; import { ModeSchema } from "./mode"; import { RelativeBroadcastSchema } from "./path"; @@ -27,6 +28,18 @@ export const JsonConfigSchema = z.looseObject({ // An optional identifier for the shape of each value, typically a JSON Schema URL. // Purely descriptive: a consumer that doesn't recognize it can still read the track. schema: z.optional(z.string()), + + // The maximum bitrate of the track in bits per second, if known. + bitrate: z.optional(u53Schema), + + // The maximum delay between a payload being ready and the publisher flushing it, in whole + // milliseconds rounded up, with the same meaning as a video rendition's `jitter`. + jitter: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 35512bac88..1e342998c2 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -1,5 +1,4 @@ -import { heapStats } from "bun:jsc"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -112,6 +111,24 @@ test("a second concurrent read is refused rather than served the first one's gro expect(await first).toEqual({ n: 0 }); }); +// Counts the reactions `run` attaches to promises still pending once it returns. A promise holds each +// reaction until it settles, so one left per iteration on a promise that outlives the loop is a leak. +// Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + // A blocked read races the frame against the track's next group, which stays pending for the whole // log. Racing it per record must not leave a reaction behind on it each time. test("blocked reads leave nothing behind on the pending group read", async () => { @@ -119,23 +136,15 @@ test("blocked reads leave nothing behind on the pending group read", async () => const producer = new Producer({ track }); const subscriber = track.subscribe(); const consumer = new Consumer({ track: subscriber }); - const promises = () => { - Bun.gc(true); - return heapStats().objectTypeCounts.Promise ?? 0; - }; - const read = async (from: number, count: number) => { - for (let n = from; n < from + count; n++) { + const reactions = await pendingReactions(async () => { + for (let n = 0; n < 1000; n++) { const next = consumer.next(); producer.append({ n }); expect((await next)?.n).toBe(n); } - }; - - await read(0, 50); - const before = promises(); - await read(50, 1000); - expect(promises() - before).toBeLessThan(100); + }); + expect(reactions).toBeLessThan(10); subscriber.close(); producer.finish(); diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 0430892187..d0f4f2c297 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -87,7 +87,7 @@ async function acceptInner( return acceptSetup(transport, url, Ietf.Version.DRAFT_16, wiring); } else if (protocol === Ietf.ALPN.DRAFT_15) { return acceptSetup(transport, url, Ietf.Version.DRAFT_15, wiring); - } else if (protocol === Lite.ALPN_07) { + } else if (protocol === Lite.ALPN_07_WIP) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_06, ...wiring }); diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 02b8e6d175..c3cedd6607 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { ALPN_05, ALPN_07 } from "../lite/version.ts"; +import { ALPN_05, ALPN_06, ALPN_07_WIP } from "../lite/version.ts"; import { createMockTransportPair } from "../mock.ts"; import { type ConnectProps, connect as connectSession } from "./connect.ts"; @@ -54,8 +54,10 @@ function stubWebTransport(transport: WebTransport): () => void { }; } -test("WebTransport offers lite-07 first by default", async () => { - const pair = createMockTransportPair(ALPN_07); +// Connect through a stubbed `new WebTransport(...)`, returning the offered protocols and +// the negotiated version. +async function offered(alpn: string, props: Omit = {}) { + const pair = createMockTransportPair(alpn); const original = globalThis.WebTransport; let protocols: string[] | undefined; @@ -66,13 +68,24 @@ test("WebTransport offers lite-07 first by default", async () => { globalThis.WebTransport = StubWebTransport as unknown as typeof WebTransport; try { - const connection = await connect(url, { websocket: { enabled: false } }); + const connection = await connect(url, { websocket: { enabled: false }, ...props }); connection.close(); + return { protocols, version: connection.version }; } finally { globalThis.WebTransport = original; } +} + +test("WebTransport offers lite-06 first by default", async () => { + const { protocols } = await offered(ALPN_06); + expect(protocols?.[0]).toBe("moq-lite-06"); + expect(protocols).not.toContain(ALPN_07_WIP); +}); - expect(protocols?.[0]).toBe("moq-lite-07"); +test("WebTransport negotiates lite-07-wip only when explicitly offered", async () => { + const { protocols, version } = await offered(ALPN_07_WIP, { webtransport: { protocols: [ALPN_07_WIP] } }); + expect(protocols).toEqual(["moq-lite-07-wip"]); + expect(version).toBe("moq-lite-07-wip"); }); test("connect logs the relay URL without its credentials", async () => { diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index b0fcbc62b1..759c3cceb0 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -281,7 +281,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): setupVersion = Ietf.Version.DRAFT_16; } else if (protocol === Ietf.ALPN.DRAFT_15) { setupVersion = Ietf.Version.DRAFT_15; - } else if (protocol === Lite.ALPN_07) { + } else if (protocol === Lite.ALPN_07_WIP) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_06, ...wiring }); @@ -438,7 +438,8 @@ async function connectWebTransport( allowPooling: false, congestionControl: "low-latency", protocols: [ - Lite.ALPN_07, + // Lite.ALPN_07_WIP is intentionally omitted: lite-07 is work-in-progress and + // not advertised by default (negotiate still accepts it if a server selects it). Lite.ALPN_06, Lite.ALPN_05, Lite.ALPN_04, @@ -525,7 +526,7 @@ async function connectWebSocket(url: URL, delay: number, cancel: Promise): // advertises every QMux draft it knows about and the server picks one. // Insertion order is the negotiation preference on the wire. const versions = { - [Lite.ALPN_07]: null, + // Lite.ALPN_07_WIP omitted on purpose: lite-07 is work-in-progress, not advertised by default. [Lite.ALPN_06]: null, [Lite.ALPN_05]: null, [Lite.ALPN_04]: null, diff --git a/js/net/src/ietf/adapter.ts b/js/net/src/ietf/adapter.ts index 11d11dff5a..fd16a5349d 100644 --- a/js/net/src/ietf/adapter.ts +++ b/js/net/src/ietf/adapter.ts @@ -58,7 +58,7 @@ export class NativeSession implements Session { const Route = { NewRequest: 0, // Create virtual bidi stream, push initial message Response: 1, // Push message to existing stream (keep open) - ErrorResponse: 2, // Push message to existing stream, then close + ErrorResponse: 2, // Push a final message to existing stream, then close CloseStream: 3, // Close stream recv (no bytes pushed) FollowUp: 4, // Push follow-up message to existing stream MaxRequestId: 5, // Update flow control @@ -658,9 +658,9 @@ export class ControlStreamAdapter implements Session { return { route: Route.CloseStream, requestId }; } case 0x0b: { - // PublishDone + // PublishDone: the subscriber reads its status and stream count before the end. const requestId = await readRequestId(); - return { route: Route.CloseStream, requestId }; + return { route: Route.ErrorResponse, requestId }; } case 0x17: { // FetchCancel diff --git a/js/net/src/ietf/ietf.test.ts b/js/net/src/ietf/ietf.test.ts index ba648c6162..1f2e04f37d 100644 --- a/js/net/src/ietf/ietf.test.ts +++ b/js/net/src/ietf/ietf.test.ts @@ -551,20 +551,19 @@ test("Subscribe v14: rejects invalid filter type", async () => { ).rejects.toThrow(); }); -test("SubscribeOk v14: rejects non-zero expires", async () => { - const invalidBytes = new Uint8Array([ - 0x01, // subscribe_id - 0x05, // INVALID: expires = 5 +test("SubscribeOk v14: ignores non-zero expires", async () => { + const body = [ + 0x01, // request_id + 0x00, // track_alias + 0x05, // expires = 5 0x02, // group_order 0x00, // content_exists 0x00, // num_params - ]); + ]; + const bytes = new Uint8Array([0x00, body.length, ...body]); - await expect( - (async () => { - await decodeVersioned(invalidBytes, Subscribe.SubscribeOk.decode, Version.DRAFT_14); - })(), - ).rejects.toThrow(); + const decoded = await decodeVersioned(bytes, Subscribe.SubscribeOk.decode, Version.DRAFT_14); + expect(decoded.requestId).toBe(1n); }); // Unicode tests diff --git a/js/net/src/ietf/object.ts b/js/net/src/ietf/object.ts index 6599d52216..974b44706b 100644 --- a/js/net/src/ietf/object.ts +++ b/js/net/src/ietf/object.ts @@ -3,6 +3,7 @@ import { Timescale, Timestamp } from "../time.ts"; import { type IetfVersion, Version } from "./version.ts"; const GROUP_END = 0x03; +const END_OF_TRACK = 0x04; // MOQ Object Property ids, shared with draft-ietf-moq-loc-04. const PROP_TIMESCALE = 0x08n; @@ -259,14 +260,24 @@ export class Group { /** A moq-transport object inside a group stream. */ export class Frame { - /** The object payload, or `undefined` for the end of group marker. */ + /** The object payload, or `undefined` for an end of group or end of track marker. */ payload?: Uint8Array; /** The presentation timestamp carried in object properties, when present. */ timestamp?: Timestamp; + /** + * An END_OF_TRACK marker: no object at or past its location exists. At object 0 its group + * does not exist either, so the track ends at that group; later in a group it ends after it. + */ + endOfTrack: boolean; - constructor({ payload, timestamp }: { payload?: Uint8Array; timestamp?: Timestamp } = {}) { + constructor({ + payload, + timestamp, + endOfTrack = false, + }: { payload?: Uint8Array; timestamp?: Timestamp; endOfTrack?: boolean } = {}) { this.payload = payload; this.timestamp = timestamp; + this.endOfTrack = endOfTrack; } /** @@ -284,7 +295,10 @@ export class Frame { await w.write(extensions); } - if (this.payload !== undefined) { + if (this.endOfTrack) { + await w.u53(0); // length = 0 + await w.u53(END_OF_TRACK); + } else if (this.payload !== undefined) { await w.u53(this.payload.byteLength); if (this.payload.byteLength === 0) { @@ -334,6 +348,9 @@ export class Frame { const status = await r.u53(); + // Defined on every implemented draft, whether or not the header marks the group's end. + if (status === END_OF_TRACK) return new Frame({ endOfTrack: true }); + if (flags.hasEnd) { // Empty frame if (status === 0) return new Frame({ payload: new Uint8Array(0), timestamp }); diff --git a/js/net/src/ietf/publish.ts b/js/net/src/ietf/publish.ts index 200d19cf78..40f105338a 100644 --- a/js/net/src/ietf/publish.ts +++ b/js/net/src/ietf/publish.ts @@ -213,21 +213,53 @@ export class PublishError { } } +/** PUBLISH_DONE status codes this implementation distinguishes. Stable across drafts 14 through 22. */ +export const PublishDoneStatus = { + INTERNAL_ERROR: 0x0, + TRACK_ENDED: 0x2, + /** Removed in draft-20, where 0x3 is unassigned. */ + SUBSCRIPTION_ENDED: 0x3, +} as const; + +/** Whether a PUBLISH_DONE status ends the track cleanly rather than aborting it. */ +export function publishDoneClean(statusCode: number, version: IetfVersion): boolean { + if (statusCode === PublishDoneStatus.TRACK_ENDED) return true; + if (statusCode !== PublishDoneStatus.SUBSCRIPTION_ENDED) return false; + switch (version) { + case Version.DRAFT_14: + case Version.DRAFT_15: + case Version.DRAFT_16: + case Version.DRAFT_17: + case Version.DRAFT_18: + case Version.DRAFT_19: + return true; + default: + return false; + } +} + // In draft-14, this message is renamed from SUBSCRIBE_DONE to PUBLISH_DONE export class PublishDone { static readonly id = 0x0b; requestId: bigint | undefined; statusCode: number; + /** + * How many data streams the publisher opened for the subscription, fill streams included. + * A hint: a peer may send 0 or the "unknown" sentinel regardless. + */ + streamCount: bigint; reasonPhrase: string; constructor({ requestId, statusCode, + streamCount = 0n, reasonPhrase, - }: { requestId?: bigint; statusCode: number; reasonPhrase: string }) { + }: { requestId?: bigint; statusCode: number; streamCount?: bigint; reasonPhrase: string }) { this.requestId = requestId; this.statusCode = statusCode; + this.streamCount = streamCount; this.reasonPhrase = reasonPhrase; } @@ -237,7 +269,7 @@ export class PublishDone { await w.u62(this.requestId); } await w.u62(BigInt(this.statusCode)); - await w.u62(BigInt(0)); // stream_count = 0 (unsupported) + await w.u62(this.streamCount); await w.string(this.reasonPhrase); } @@ -255,9 +287,9 @@ export class PublishDone { ? await r.u62() : undefined; const statusCode = Number(await r.u62()); - await r.u62(); // ignore stream_count + const streamCount = await r.u62(); const reasonPhrase = await r.string(); - return new PublishDone({ requestId, statusCode, reasonPhrase }); + return new PublishDone({ requestId, statusCode, streamCount, reasonPhrase }); } } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 0d0908fb2f..a16ce4ccbc 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -14,7 +14,7 @@ import { wireOf } from "../wire.ts"; import { NativeSession, type Session } from "./adapter.ts"; import type * as Cluster from "./cluster.ts"; import { FetchHeader } from "./fetch.ts"; -import { Group as GroupMessage } from "./object.ts"; +import { Frame, Group as GroupMessage } from "./object.ts"; import { PublishDone } from "./publish.ts"; import { PublishNamespace } from "./publish_namespace.ts"; import { Publisher } from "./publisher.ts"; @@ -908,6 +908,16 @@ async function readGroup(stream: ReadableStream): Promise): Promise { + const reader = new Reader(stream, undefined, V20); + const header = await GroupMessage.decode(reader, V20); + const frame = await Frame.decode(reader, header.flags, undefined, V20); + expect(frame.endOfTrack).toBe(true); + expect(await reader.done()).toBe(true); + return header.groupId; +} + /** * Read a fill's fetch stream to its end, reporting a reset rather than throwing. * @@ -1339,8 +1349,12 @@ test("draft-20: a clean close past a bounded filter's end still sends PUBLISH_DO expect(await client.reader.u53()).toBe(PublishDone.id); const done = await PublishDone.decode(client.reader, V20); expect(done.statusCode).toBe(TRACK_ENDED_STATUS); + expect(done.streamCount).toBe(2n); - // Only the in-range group was ever opened. + // Only the in-range group was ever served; the other stream marks the track's end. + const end = await nextUni(fx.uni); + if (!end) throw new Error("the track's end was never marked"); + expect(await readEndOfTrack(end)).toBe(2); expect(await nextUni(fx.uni)).toBeUndefined(); } finally { fx.close(); @@ -1454,3 +1468,61 @@ test("draft-20: a fill works on a dynamically requested track", async () => { client.close(); } }); + +// PUBLISH_DONE MUST wait until every stream the subscription will open is closed, so its +// Stream Count is final. A group still queued for a stream slot when the track ends is one. +test("draft-20: PUBLISH_DONE waits for a queued group and counts every stream", async () => { + const fx = fixture(); + const track = fx.broadcast.createTrack("video"); + + // Park the first stream open, the way a transport at its stream cap does. + const slot = Promise.withResolvers(); + const create = fx.pair.server.createUnidirectionalStream.bind(fx.pair.server); + let parked = false; + fx.pair.server.createUnidirectionalStream = async (options?: WebTransportSendStreamOptions) => { + if (!parked) { + parked = true; + await slot.promise; + } + return create(options); + }; + + const { client } = await runSubscribe( + fx, + new Subscribe({ + requestId: 7n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 0, + filter: { kind: "absolute", startGroup: 0n, startObject: 0n }, + }), + ); + + try { + writeGroup(track, 1); + track.close(); + + // Nothing ends the subscription while the group waits for its slot. + const response = client.reader.u53(); + const idle = new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)); + expect(await Promise.race([response, idle])).toBe("pending"); + + slot.resolve(); + const served = await nextUni(fx.uni); + if (!served) throw new Error("the queued group was never served"); + expect((await readGroup(served)).sequence).toBe(0); + + expect(await response).toBe(PublishDone.id); + const done = await PublishDone.decode(client.reader, V20); + expect(done.statusCode).toBe(TRACK_ENDED_STATUS); + // The group's stream and the END_OF_TRACK marker's. + expect(done.streamCount).toBe(2n); + + const end = await nextUni(fx.uni); + if (!end) throw new Error("the track's end was never marked"); + expect(await readEndOfTrack(end)).toBe(1); + } finally { + fx.close(); + client.close(); + } +}); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 0074eef517..a14dbb4ce2 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -7,7 +7,7 @@ import { hiddenBelow, hooks } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; -import { Milli, type Timescale } from "../time.ts"; +import { Milli, Timescale } from "../time.ts"; import type { Subscriber as TrackSubscriber } from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; @@ -20,7 +20,7 @@ import * as Filter from "./filter.ts"; import { FetchFrame, Frame, Group as GroupMessage } from "./object.ts"; import { fromWire, toWire } from "./priority.ts"; import * as Properties from "./properties.ts"; -import { PublishDone } from "./publish.ts"; +import { PublishDone, PublishDoneStatus } from "./publish.ts"; import { PublishNamespace, PublishNamespaceDone, PublishNamespaceOk } from "./publish_namespace.ts"; import { RequestError, RequestOk } from "./request.ts"; import { type Subscribe, SubscribeError, SubscribeOk } from "./subscribe.ts"; @@ -52,12 +52,6 @@ function sameAdvert(a: Advertised | undefined, b: Advertised | undefined): boole return a !== undefined && b !== undefined && a.identity === b.identity && routesEqual(a.route, b.route); } -/** PUBLISH_DONE statuses this implementation emits. Stable across drafts 14 through 19. */ -const PUBLISH_DONE_STATUS = { - INTERNAL_ERROR: 0x0, - TRACK_ENDED: 0x2, -} as const; - /** * How long one advertisement may take to be answered. Matches the Rust publisher, and the * peer accepting the stream is only half the exchange: one it never answers on holds the @@ -108,8 +102,14 @@ interface RunGroup { /** Settles when the subscriber leaves, dropping a group still queued for a stream slot. */ unsubscribed: Promise; + + /** The subscription's data stream count, which PUBLISH_DONE reports. */ + streams: StreamCount; } +/** How many data streams a subscription opened, fill streams included. */ +type StreamCount = { opened: number }; + /** What {@link Publisher.runFill} needs to serve one subscription's backfill. */ interface RunFill { /** The subscription's request ID, which the fetch stream names. */ @@ -141,6 +141,9 @@ interface RunFill { /** Settles when the subscriber leaves, releasing a fill still waiting on its group. */ unsubscribed: Promise; + + /** The subscription's data stream count, which PUBLISH_DONE reports. */ + streams: StreamCount; } /** @@ -349,6 +352,11 @@ export class Publisher { () => unsubscribe(), ); + // Every group started, until its stream finishes or resets, and the data streams + // opened for PUBLISH_DONE to report. + const groups = new Set>(); + const streams: StreamCount = { opened: 0 }; + // Serve track groups, racing with stream close (= Unsubscribe) const serving = (async () => { for (;;) { @@ -365,7 +373,7 @@ export class Publisher { continue; } - void this.#runGroup({ + const task = this.#runGroup({ requestId: msg.requestId, group, timescale, @@ -373,7 +381,10 @@ export class Publisher { stamped: msg.propertiesWanted, slice: groupSlice(range, group.sequence), unsubscribed, + streams, }); + groups.add(task); + void task.finally(() => groups.delete(task)); } })(); @@ -389,16 +400,38 @@ export class Publisher { timescale, stamped: msg.propertiesWanted, unsubscribed, + streams, }) : Promise.resolve(); let publishError: Error | undefined; + let ended = false; try { - await race([Promise.all([serving, filling]), stream.reader.closed]); + const served = Symbol("served"); + ended = + (await race([Promise.all([serving, filling]).then(() => served), stream.reader.closed])) === served; } catch (err: unknown) { publishError = error(err); } + // PUBLISH_DONE waits until every stream this subscription will open is closed, as + // the draft requires, so its count is final. The subscriber leaving cancels the + // ones still queued instead. + await race([Promise.all(groups), unsubscribed]); + + // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what + // tells the subscriber where the track ended. + const final = track.final(); + if (ended && !publishError && final !== undefined) { + await this.#runEndOfTrack({ + requestId: msg.requestId, + final, + publisherPriority, + unsubscribed, + streams, + }); + } + console.debug(`publish done: broadcast=${name} track=${track.name}`); if (publishError) { console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(publishError)}`); @@ -413,7 +446,8 @@ export class Publisher { version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED, + statusCode: publishError ? PublishDoneStatus.INTERNAL_ERROR : PublishDoneStatus.TRACK_ENDED, + streamCount: BigInt(streams.opened), reasonPhrase: publishError ? "internal error" : "track ended", }); await done.encode(stream.writer, version); @@ -442,7 +476,7 @@ export class Publisher { * Runs a group and sends its frames using ObjectStream (Subgroup delivery mode). */ async #runGroup(options: RunGroup) { - const { requestId, group, timescale, publisherPriority, stamped, slice, unsubscribed } = options; + const { requestId, group, timescale, publisherPriority, stamped, slice, unsubscribed, streams } = options; try { // One stream per group is faster than a peer at its limit can retire them, so this // is the one path that doesn't wait for a slot: the transport would serve the opens @@ -457,6 +491,7 @@ export class Publisher { group.close(new Error("no stream slot")); return; } + streams.opened += 1; const header = new GroupMessage({ trackAlias: requestId, @@ -525,6 +560,49 @@ export class Publisher { } } + /** + * Mark the track's end with an END_OF_TRACK object on its own stream, at object 0 of the + * group that will never exist. + * + * The last group's stream has usually finished before the track ends, so the marker cannot + * ride on it. A failure only costs the subscriber the early boundary. + */ + async #runEndOfTrack(options: { + requestId: bigint; + final: number; + publisherPriority: number; + unsubscribed: Promise; + streams: StreamCount; + }) { + const { requestId, final, publisherPriority, unsubscribed, streams } = options; + const version = this.#session.version; + const stream = await Writer.tryOpen(this.#quic, { cancel: unsubscribed, version }).catch(() => undefined); + if (!stream) return; + streams.opened += 1; + + try { + const header = new GroupMessage({ + trackAlias: requestId, + groupId: final, + subGroupId: 0, + publisherPriority, + flags: { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: false, + hasPriority: true, + firstObject: true, + }, + }); + await header.encode(stream, version); + await new Frame({ endOfTrack: true }).encode(stream, header.flags, Timescale.MILLI, version); + stream.close(); + } catch (err: unknown) { + stream.reset(error(err)); + } + } + /** * Serve a draft-20 fill on its own fetch stream: the requested range, read from the * group cache, capped at the Largest Object snapshot. @@ -534,7 +612,7 @@ export class Publisher { * fill-failure signal. Nothing here touches the subscription either way. */ async #runFill(options: RunFill) { - const { requestId, fill, cache, timescale, stamped, unsubscribed } = options; + const { requestId, fill, cache, timescale, stamped, unsubscribed, streams } = options; const version = this.#session.version; // Everything is inside the try so the cache fork is released on every path out, @@ -548,6 +626,7 @@ export class Publisher { console.debug(`fill stream failed to open: fill=${requestId}`); return; } + streams.opened += 1; await stream.u53(FetchHeader.type); await new FetchHeader({ requestId }).encode(stream, version); diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts index ca3cbc4930..51973c7baa 100644 --- a/js/net/src/ietf/subscribe.ts +++ b/js/net/src/ietf/subscribe.ts @@ -276,10 +276,9 @@ export class SubscribeOk { let properties: Properties.Properties = {}; if (version === Version.DRAFT_14) { - const expires = await r.u62(); - if (expires !== BigInt(0)) { - throw new Error(`unsupported expires: ${expires}`); - } + // EXPIRES is when the publisher expects to end the subscription. That end + // arrives as PUBLISH_DONE regardless, so there is nothing to act on. + await r.u62(); await r.u8(); // Don't care about group order diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 33e8595b92..b107b0ca23 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -8,6 +8,7 @@ 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 { TAIL_GRACE_MS, Tail } from "../tail.ts"; import { type Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; @@ -18,7 +19,7 @@ import * as Cluster from "./cluster.ts"; import { requestReason, toRequestCode } from "./error.ts"; import { Frame, type Group as GroupMessage } from "./object.ts"; import { fromWire, toWire } from "./priority.ts"; -import { type Publish, PublishError } from "./publish.ts"; +import { type Publish, PublishDone, PublishError, publishDoneClean } from "./publish.ts"; import { type PublishNamespace, PublishNamespaceDone, @@ -44,6 +45,15 @@ import { Version } from "./version.ts"; // blocks. The timeout turns that into a clear error. const SUBSCRIBE_OK_TIMEOUT_MS = 10_000; +// A live subscription, as the track alias its data streams name resolves to. +type Subscription = { + // The write side incoming group streams are routed into. + track: track.Producer; + // The group streams received, so the subscription can wait for the ones PUBLISH_DONE + // says are still owed. + tail: Tail; +}; + // Out-parameter for #openSubscribe: lets the caller observe partial progress // (stream opened, trackAlias registered) so it can clean up on timeout even // before the setup promise settles. @@ -93,7 +103,7 @@ export class Subscriber { #cluster?: Cluster.Hops; // Publisher-chosen aliases used by incoming group streams. - #aliases = new TrackAliases(); + #aliases = new TrackAliases(); // Units for each track's object Timestamps, from the TIMESCALE Track Property in // SUBSCRIBE_OK. A track missing from this map declared no timeline, so the publisher @@ -481,12 +491,13 @@ export class Subscriber { // Keep the request pending until SUBSCRIBE_OK supplies immutable track metadata. // Group streams already wait on the alias, so early data stays behind this response. const producer = hooks.pendingTrackProducer(request); + const subscription: Subscription = { track: producer, tail: new Tail() }; // Open the stream and wait for SUBSCRIBE_OK under a timeout. State // flows back via `state` so the timeout path can clean up the stream // and any registration if setup eventually finishes. const state: SubscribeSetupState = {}; - const setup = this.#openSubscribe(state, broadcast, request, producer, requestId); + const setup = this.#openSubscribe(state, broadcast, request, subscription, requestId); // The publisher can be serving before it answers, so waiting only on the response // would miss the local side going away and leave it serving a track nobody reads. @@ -534,7 +545,7 @@ export class Subscriber { const cleanup = async (afterSetup: boolean) => { state.cancelled = true; - if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, producer)) { + if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, subscription)) { this.#timescales.delete(state.registeredAlias); } @@ -575,10 +586,10 @@ export class Subscriber { const localEnded = Symbol("local"); const idle = Symbol("idle"); - // Terminal conditions settle at most once (stream close = PublishDone, track close = - // local unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. + // Terminal conditions settle at most once (PublishDone, track close = local + // unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. const done = race([ - stream.reader.closed.then(() => publisherEnded), + this.#runPublishDone(stream, subscription).then(() => publisherEnded), producer.closed.then(() => localEnded), ]); @@ -615,8 +626,37 @@ export class Subscriber { } finally { // Only the owner tears down the alias metadata: a later subscription may have // reclaimed the alias and installed its own timescale. - if (this.#aliases.retire(trackAlias, producer)) this.#timescales.delete(trackAlias); + if (this.#aliases.retire(trackAlias, subscription)) this.#timescales.delete(trackAlias); + } + } + + /** + * Read the PUBLISH_DONE that ends a subscription, then wait for the data streams it counts. + * + * An error status aborts the track with it. A clean one leaves streams in flight, since + * QUIC does not order them, so wait until the Stream Count many have been read, or a + * bounded grace for the ones that never arrive (the draft says to use a timeout). The count + * is only a hint: a peer may send 0 regardless, so 0 waits out the grace. A request stream + * that ends without one ends the track the same way. + */ + async #runPublishDone(stream: Stream, subscription: Subscription): Promise { + const version = this.#session.version; + let count: bigint | undefined; + if (!(await stream.reader.done())) { + const typeId = await stream.reader.u53(); + if (typeId !== PublishDone.id) { + throw new ProtocolViolation(`unexpected message on a subscription: 0x${typeId.toString(16)}`); + } + const done = await PublishDone.decode(stream.reader, version); + if (!publishDoneClean(done.statusCode, version)) { + throw new Error(`publish done: status=0x${done.statusCode.toString(16)} reason=${done.reasonPhrase}`); + } + count = done.streamCount; } + + const { tail, track } = subscription; + const complete = () => count !== undefined && count > 0n && BigInt(tail.streams) >= count; + await tail.settle(complete, TAIL_GRACE_MS, track.closed); } /** @@ -648,7 +688,7 @@ export class Subscriber { state: SubscribeSetupState, broadcast: Path.Valid, request: track.Request, - producer: track.Producer, + subscription: Subscription, requestId: bigint, ): Promise<{ stream: Stream; alias: bigint }> { const version = this.#session.version; @@ -706,7 +746,7 @@ export class Subscriber { request.accept({ priority: fromWire(ok.properties.priority ?? 128) }); try { - this.#aliases.set(ok.trackAlias, producer, { broadcast, name: request.name }); + this.#aliases.set(ok.trackAlias, subscription, { broadcast, name: request.name }); const timescale = ok.properties.timescale; if (timescale !== undefined) { this.#timescales.set(ok.trackAlias, timescale); @@ -935,35 +975,62 @@ export class Subscriber { throw new Error("subgroups are not supported"); } - // FIRST_OBJECT clear says this stream starts partway through the group, which the - // draft lets a publisher do to answer a filter. Nothing above here can use it: the - // objects that would arrive are not decodable without the missing head, and a group - // is the unit an application resyncs on. Drop it and pick up at the next group, the - // same degradation as a publisher that no longer holds the head. - // - // This only saves reading a stream we would throw away. The bit is the publisher's - // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every - // object to starting at 0 and incrementing by 1, whatever the header said and on the - // drafts that have no such bit to read. - if (!group.flags.firstObject) { - console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); - stream.stop(new Error("a group must start at object 0")); + let subscription: Subscription; + try { + // The control message establishing this alias can arrive after the data stream. + subscription = await this.#aliases.get(group.trackAlias); + } catch (err: unknown) { + const e = error(err); + // Ours: we cancelled the subscription and the publisher has not stopped yet. + // Anything else on this alias is the publisher sending data for a track it never + // acknowledged, which is worth seeing. + if (e instanceof RetiredTrackAlias) { + console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`); + } + stream.stop(e); return; } - const producer = new netGroup.Producer(group.groupId); + const { track, tail } = subscription; + // Every data stream counts toward PUBLISH_DONE's Stream Count, even one dropped below. + const read = tail.open(group.groupId); + + // Created on the first object rather than the header: an END_OF_TRACK at object 0 + // means the group does not exist at all. + let producer: netGroup.Producer | undefined; + const open = () => { + if (!producer) { + producer = new netGroup.Producer(group.groupId); + track.writeGroup(producer); + } + return producer; + }; try { - // The control message establishing this alias can arrive after the data stream. - const track = await this.#aliases.get(group.trackAlias); + // FIRST_OBJECT clear says this stream starts partway through the group, which the + // draft lets a publisher do to answer a filter. Nothing above here can use it: the + // objects that would arrive are not decodable without the missing head, and a group + // is the unit an application resyncs on. Drop it and pick up at the next group, the + // same degradation as a publisher that no longer holds the head. + // + // This only saves reading a stream we would throw away. The bit is the publisher's + // claim, so what is enforced is the object ids themselves: `Frame.decode` holds every + // object to starting at 0 and incrementing by 1, whatever the header said and on the + // drafts that have no such bit to read. + if (!group.flags.firstObject) { + console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`); + stream.stop(new Error("a group must start at object 0")); + return; + } + // The alias binds after SUBSCRIBE_OK commits the track property; an omitted // header priority inherits it (draft-21 section 10.4). if (!group.flags.hasPriority) group.publisherPriority = toWire((await track.info()).priority); - track.writeGroup(producer); - for (;;) { - const done = await race([stream.done(), producer.closed, track.closed]); + // Only the group's own stream ends it: a track that closes first has already + // closed (or aborted) this group through its cache. + const done = await (producer ? race([stream.done(), producer.closed]) : stream.done()); if (done !== false) break; const frame = await Frame.decode( @@ -972,22 +1039,44 @@ export class Subscriber { this.#timescales.get(group.trackAlias), this.#session.version, ); + + if (frame.endOfTrack) { + // No object at or past this location exists: after the group's last object + // the track ends with it, and at object 0 it ends before it. + const end = producer ? group.groupId + 1 : group.groupId; + producer?.close(); + try { + track.finishAt(end); + } catch (err: unknown) { + throw new ProtocolViolation(`invalid END_OF_TRACK: ${reason(error(err))}`); + } + return; + } if (frame.payload === undefined) break; - producer.writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() }); + open().writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() }); } - producer.close(); + // A group with no objects still exists. + open().close(); } catch (err: unknown) { const e = error(err); - // Ours: we cancelled the subscription and the publisher has not stopped yet. - // Anything else on this alias is the publisher sending data for a track it never - // acknowledged, which is worth seeing. - if (e instanceof RetiredTrackAlias) { - console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`); + if (e instanceof ProtocolViolation) { + // The publisher broke the track's end, which no later group can repair. + producer?.close(e); + track.close(e); + } else { + // A stream that fails before its first object still names a group, which the + // reader sees fail rather than silently go missing. + try { + open().close(e); + } catch { + // The track has already closed or ended below this group. + } } - producer.close(e); stream.stop(e); + } finally { + read(); } } } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts new file mode 100644 index 0000000000..d7a505f642 --- /dev/null +++ b/js/net/src/ietf/tail.test.ts @@ -0,0 +1,177 @@ +import { expect, test } from "bun:test"; +import type { Consumer as GroupConsumer } from "../group.ts"; +import { createMockTransportPair } from "../mock.ts"; +import * as Path from "../path.ts"; +import { Reader, Stream } from "../stream.ts"; +import { TAIL_GRACE_MS } from "../tail.ts"; +import { Milli } from "../time.ts"; +import { NativeSession } from "./adapter.ts"; +import { type GroupFlags, Group as GroupMessage } from "./object.ts"; +import { PublishDone } from "./publish.ts"; +import { Subscribe, SubscribeOk } from "./subscribe.ts"; +import { Subscriber } from "./subscriber.ts"; +import { ALPN, Version } from "./version.ts"; + +const VERSION = Version.DRAFT_19; +const ALIAS = 9n; +const TRACK_ENDED = 0x2; +const INTERNAL_ERROR = 0x0; + +// A plain subgroup stream: no extensions, no subgroup id, end of group on FIN. +const FLAGS: GroupFlags = { + hasExtensions: false, + hasSubgroup: false, + hasSubgroupObject: false, + hasEnd: true, + hasPriority: true, + firstObject: true, +}; + +/** One object with a zero id delta. Every field is under 64, so each is a one-byte varint. */ +function object(payload: string): Uint8Array { + const bytes = new TextEncoder().encode(payload); + return new Uint8Array([0, bytes.byteLength, ...bytes]); +} + +/** An END_OF_TRACK object: zero length, then status 0x4. */ +const END_OF_TRACK = new Uint8Array([0, 0, 0x4]); + +/** A group stream the test writes by hand, handed to the subscriber as if it arrived. */ +function groupStream(subscriber: Subscriber, groupId: number) { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ start: (c) => (controller = c) }); + const header = new GroupMessage({ trackAlias: ALIAS, groupId, subGroupId: 0, publisherPriority: 0, flags: FLAGS }); + const handled = subscriber.handleGroup(header, new Reader(readable, undefined, VERSION)); + return { + write: (bytes: Uint8Array) => controller.enqueue(bytes), + finish: () => controller.close(), + handled, + }; +} + +/** A subscriber with one track subscribed and answered; the test plays the publisher. */ +async function subscribed() { + const pair = createMockTransportPair(ALPN.DRAFT_19); + const session = new NativeSession(pair.server, VERSION, true); + const subscriber = new Subscriber({ session }); + const reader = subscriber + .consume(Path.from("room")) + .track("video") + .subscribe({ maxAge: Milli(60_000) }); + + const peer = await Stream.accept(pair.client, VERSION); + if (!peer) throw new Error("the subscriber never opened a subscribe stream"); + expect(await peer.reader.u53()).toBe(Subscribe.id); + const request = await Subscribe.decode(peer.reader, VERSION); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ requestId: request.requestId, trackAlias: ALIAS }).encode(peer.writer, VERSION); + + return { + subscriber, + reader, + done: async (statusCode: number, streamCount: bigint) => { + await peer.writer.u53(PublishDone.id); + await new PublishDone({ statusCode, streamCount, reasonPhrase: "done" }).encode(peer.writer, VERSION); + peer.writer.close(); + }, + }; +} + +async function readAll(group: GroupConsumer | undefined): Promise { + if (!group) throw new Error("no group"); + const out: string[] = []; + for (;;) { + const next = await group.readString(); + if (next === undefined) return out; + out.push(next); + } +} + +test("a group stream that arrives after PUBLISH_DONE is delivered", async () => { + const { subscriber, reader, done } = await subscribed(); + const first = groupStream(subscriber, 0); + first.write(object("0.0")); + first.finish(); + await first.handled; + await done(TRACK_ENDED, 2n); + + // QUIC does not order streams, so the second one lands after PUBLISH_DONE. + const started = performance.now(); + const late = groupStream(subscriber, 1); + late.write(object("1.0")); + late.finish(); + + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + // The Stream Count was met, so nothing waited out the grace. + expect(performance.now() - started).toBeLessThan(TAIL_GRACE_MS); +}); + +test("a group read across PUBLISH_DONE is delivered whole", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 0); + group.write(object("0.0")); + await done(TRACK_ENDED, 1n); + + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + group.write(object("0.1")); + group.finish(); + expect(await readAll(received)).toEqual(["0.1"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); +}); + +test("a Stream Count of 0 is a hint, so a late stream within the grace is still delivered", async () => { + const { subscriber, reader, done } = await subscribed(); + const started = performance.now(); + await done(TRACK_ENDED, 0n); + + const late = groupStream(subscriber, 0); + late.write(object("0.0")); + late.finish(); + + expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); + expect(performance.now() - started).toBeGreaterThanOrEqual(TAIL_GRACE_MS - 5); +}); + +test("a PUBLISH_DONE with an error status aborts the track", async () => { + const { reader, done } = await subscribed(); + await done(INTERNAL_ERROR, 0n); + expect(await reader.closed).toBeInstanceOf(Error); +}); + +test("END_OF_TRACK after a group's last object ends the track after that group", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 4); + group.write(object("4.0")); + group.write(END_OF_TRACK); + group.finish(); + + expect(await reader.finished()).toBe(5); + expect(await readAll(await reader.recvGroup())).toEqual(["4.0"]); + + await done(TRACK_ENDED, 1n); + expect(await reader.recvGroup()).toBeUndefined(); + expect(reader.final()).toBe(5); +}); + +test("END_OF_TRACK at object 0 ends the track before its group, which never exists", async () => { + const { subscriber, reader, done } = await subscribed(); + const group = groupStream(subscriber, 0); + group.write(object("0.0")); + group.finish(); + const end = groupStream(subscriber, 2); + end.write(END_OF_TRACK); + end.finish(); + + expect(await reader.finished()).toBe(2); + await done(TRACK_ENDED, 2n); + expect((await reader.recvGroup())?.sequence).toBe(0); + expect(await reader.recvGroup()).toBeUndefined(); + expect(reader.final()).toBe(2); +}); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 2a98a9a8bc..3107cb2b14 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -421,7 +421,7 @@ async function announcedUntil(announced: { next(): Promise<{ prefix: Path.Valid // A `.`-named broadcast is left out of discovery unless the request opts in or names the // dot segment. lite-06 cannot carry the opt-in, so its peer never lists the hidden path. for (const [protocol, carriesOptIn] of [ - [Lite.ALPN_07, true], + [Lite.ALPN_07_WIP, true], [Lite.ALPN_06, false], [Ietf.ALPN.DRAFT_19, true], [Ietf.ALPN.DRAFT_16, true], diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index d8092a083f..bbaf0a2731 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -15,8 +15,8 @@ import { Group as GroupMessage } from "./group.ts"; import { sendOrder } from "./priority.ts"; import { Probe as ProbeMessage } from "./probe.ts"; import { Publisher } from "./publisher.ts"; -import { decodeSubscribeResponse, Subscribe, SubscribeUpdate } from "./subscribe.ts"; -import { ALPN_05, ALPN_06, Version } from "./version.ts"; +import { decodeSubscribeResponse, Subscribe, type SubscribeEnd, SubscribeUpdate } from "./subscribe.ts"; +import { ALPN_05, ALPN_06, ALPN_07_WIP, Version } from "./version.ts"; function publish(origin: OriginProducer, path: Path.Valid) { const broadcast = origin.createBroadcast(path); @@ -85,11 +85,11 @@ test.each([Version.DRAFT_01, Version.DRAFT_03, Version.DRAFT_06])( ); // Delivers `sequences` in the given order, finishes the track, and returns the -// SUBSCRIBE_END boundary the publisher put on the wire. -async function subscribeEnd(sequences: number[]): Promise { - const pair = createMockTransportPair(ALPN_05); +// SUBSCRIBE_END the publisher put on the wire. +async function subscribeEnd(sequences: number[], version: Version = Version.DRAFT_05): Promise { + const pair = createMockTransportPair(version === Version.DRAFT_07 ? ALPN_07_WIP : ALPN_05); const origin = new OriginProducer(); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomHop(), origin.consume()); + const publisher = new Publisher(pair.server, version, randomHop(), origin.consume()); const broadcast = publish(origin, Path.from("test")); const track = broadcast.createTrack("video"); @@ -118,8 +118,8 @@ async function subscribeEnd(sequences: number[]): Promise { try { for (;;) { - const resp = await decodeSubscribeResponse(client.reader, Version.DRAFT_05); - if ("end" in resp) return resp.end.group; + const resp = await decodeSubscribeResponse(client.reader, version); + if ("end" in resp) return resp.end; } } finally { publisher.close(); @@ -972,19 +972,153 @@ test("lite draft-05: teardown unwinds with an undelivered update queued", async // A Rust subscriber feeds this value straight into `track::Producer::finish_at`, which is // exclusive, so an inclusive bound here silently truncates the final group across languages. test("lite draft-05: subscribe end is the exclusive boundary", async () => { - expect(await subscribeEnd([0, 1, 2])).toBe(3); + expect((await subscribeEnd([0, 1, 2])).group).toBe(3); }); // recvGroup is arrival-ordered, so the boundary has to clear the max sequence delivered, // not the last one seen. Otherwise the boundary lands on a group already on the wire. test("lite draft-05: subscribe end clears the max sequence when groups arrive out of order", async () => { - expect(await subscribeEnd([0, 2, 1])).toBe(3); + expect((await subscribeEnd([0, 2, 1])).group).toBe(3); }); // 0 is the only encoding for "no groups at all"; an inclusive bound cannot express it // without colliding with a track whose sole group was sequence 0. test("lite draft-05: subscribe end is 0 when no groups were produced", async () => { - expect(await subscribeEnd([])).toBe(0); + expect((await subscribeEnd([])).group).toBe(0); +}); + +// The count is of group streams opened, not of groups below the end: a group the track +// never produced has no stream and is not counted. +test("lite draft-07: subscribe end counts the group streams opened", async () => { + const end = await subscribeEnd([0, 2], Version.DRAFT_07); + expect([end.group, end.streams]).toEqual([3, 2]); +}); + +test("lite draft-07: subscribe end counts zero streams when no groups were produced", async () => { + const end = await subscribeEnd([], Version.DRAFT_07); + expect([end.group, end.streams]).toEqual([0, 0]); +}); + +// finishAt names the end while groups below it are still being produced. The count +// cannot include a stream that has not opened, so SUBSCRIBE_END waits for them. +test("lite draft-07: subscribe end waits for groups below a declared finish", async () => { + const pair = createMockTransportPair(ALPN_07_WIP); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_07, randomHop(), origin.consume()); + const broadcast = publish(origin, Path.from("test")); + const track = broadcast.createTrack("video"); + + 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"); + void publisher.runSubscribe( + new Subscribe({ id: 0n, broadcast: Path.from("test"), track: "video", priority: 0 }), + server, + ); + + try { + const first = new GroupProducer(0); + first.writeString("hello"); + first.close(); + track.writeGroup(first); + track.finishAt(2); + + const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); + expect("start" in start).toBe(true); + const pending = decodeSubscribeResponse(client.reader, Version.DRAFT_07); + const early = await Promise.race([pending, new Promise((resolve) => setTimeout(resolve, IDLE_MS))]); + expect(early).toBeUndefined(); + + const second = new GroupProducer(1); + second.writeString("hello"); + second.close(); + track.writeGroup(second); + track.close(); + + const resp = await pending; + if (!("end" in resp)) throw new Error("expected SUBSCRIBE_END"); + expect([resp.end.group, resp.end.streams]).toEqual([2, 2]); + } finally { + publisher.close(); + client.close(); + } +}); + +// Serves one group with its stream open held until `open(ok)`, and returns the pending +// SUBSCRIBE_END plus the call that lets the open succeed or fail. +async function heldOpenEnd() { + const pair = createMockTransportPair(ALPN_07_WIP); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_07, randomHop(), origin.consume()); + const broadcast = publish(origin, Path.from("test")); + const track = broadcast.createTrack("video"); + + let open!: (ok: boolean) => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + const createUni = pair.server.createUnidirectionalStream.bind(pair.server); + spyOn(pair.server, "createUnidirectionalStream").mockImplementation(async (options) => { + if (!(await opened)) throw new Error("no stream credit"); + return createUni(options); + }); + + 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"); + void publisher.runSubscribe( + new Subscribe({ id: 0n, broadcast: Path.from("test"), track: "video", priority: 0 }), + server, + ); + + const group = new GroupProducer(0); + group.writeString("hello"); + group.close(); + track.writeGroup(group); + track.close(); + + const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); + expect("start" in start).toBe(true); + const end = decodeSubscribeResponse(client.reader, Version.DRAFT_07); + + return { + end, + open, + close() { + publisher.close(); + client.close(); + }, + }; +} + +// The count is final only once no served group is still waiting for its stream, so +// SUBSCRIBE_END waits for the open. +test("lite draft-07: subscribe end waits for every group stream to open", async () => { + const held = await heldOpenEnd(); + try { + const early = await Promise.race([held.end, new Promise((resolve) => setTimeout(resolve, IDLE_MS))]); + expect(early).toBeUndefined(); + + held.open(true); + const resp = await held.end; + if (!("end" in resp)) throw new Error("expected SUBSCRIBE_END"); + expect([resp.end.group, resp.end.streams]).toEqual([1, 1]); + } finally { + held.close(); + } +}); + +// A group that never gets a stream owes the subscriber nothing, so it is not counted. +test("lite draft-07: a group whose stream never opened is not counted", async () => { + const held = await heldOpenEnd(); + try { + held.open(false); + const resp = await held.end; + if (!("end" in resp)) throw new Error("expected SUBSCRIBE_END"); + expect([resp.end.group, resp.end.streams]).toEqual([1, 0]); + } finally { + held.close(); + } }); /** One group stream the publisher put on the wire. */ @@ -1325,22 +1459,27 @@ test("lite draft-05: a group waiting for a stream slot is dropped when the subsc // The publisher FINs the subscribe stream itself once a track ends, which must not be // mistaken for the subscriber leaving: SUBSCRIBE_END counts those queued groups as -// delivered, so dropping them here would strand the tail of every finite track. +// delivered, so dropping them here would strand the tail of every finite track. The FIN +// tells the subscriber every group is accounted for, so it waits for the queued group. test("lite draft-05: a group waiting for a stream slot survives the track finishing", async () => { const { client, track, freeSlot, outcome, close } = await saturatedGroup(); track.close(); - // Read to the FIN the publisher sends after SUBSCRIBE_END. That FIN is the moment a - // cancel keyed on our own close would fire, so the slot must not free up before it. + // SUBSCRIBE_END goes out while the group is still waiting for its slot. for (;;) { const resp = await decodeSubscribeResponse(client.reader, Version.DRAFT_05); if ("end" in resp) break; } - await client.reader.closed; + + // The FIN holds until the queued group is on the wire. + const fin = client.reader.closed.then(() => "fin" as const); + const idle = new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)); + expect(await Promise.race([fin, idle])).toBe("pending"); freeSlot(); expect(await outcome).toBe("sent"); + expect(await fin).toBe("fin"); close(); }); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index bff615b0e9..f35fce72be 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -27,7 +27,15 @@ import { SubscribeUpdate, } from "./subscribe.ts"; import { TrackInfo as TrackInfoMessage, type Track as TrackMessage } from "./track.ts"; -import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, resolvesStart, Version } from "./version.ts"; +import { + hasAnnounceId, + hasAnnounceOk, + hasDatagrams, + hasProbeRtt, + hasStreamCount, + resolvesStart, + 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 @@ -64,7 +72,7 @@ function zigzag(delta: bigint): bigint { return delta >= 0n ? delta << 1n : (-delta << 1n) - 1n; } -/** What {@link Publisher.runGroup} needs to serve one group. */ +/** What {@link Publisher.openGroup} and {@link Publisher.serveGroup} need to serve one group. */ interface RunGroup { /** The subscription ID. */ sub: bigint; @@ -245,6 +253,11 @@ class SubscriptionControls { return false; } + /** Settles once the stream is over: `null` when it ended cleanly, or the failure. */ + get ended(): Promise { + return this.#ended; + } + #finish(end: Error | null) { if (this.#end !== undefined) return; this.#end = end; @@ -732,6 +745,13 @@ export class Publisher { let startSent = false; let endSent = false; + // Lite-07+ counts the group streams in SUBSCRIBE_END, so it goes out only once every + // served group has opened its stream or given up, and a cap holding groups back + // delays it until they are released. + const countStreams = hasStreamCount(this.version); + let streams = 0; + const opening = new Set>(); + // The track's exclusive final boundary. A Rust subscriber feeds SUBSCRIBE_END // straight into finish_at, so it must name the track's boundary (which counts // datagram sequences too), not the delivered range: a subscription cap can hold @@ -739,22 +759,28 @@ export class Publisher { // before the producer declared it. const boundary = () => track.final() ?? (track.latest() ?? -1) + 1; - // SUBSCRIBE_END names that boundary, which a cap can hold groups back from, so it goes - // out as soon as the producer finishes and the subscription keeps serving whatever a - // later cap raise releases (see the Rust publisher's Recv::Boundary). + // Before lite-07, SUBSCRIBE_END names that boundary, which a cap can hold groups back + // from, so it goes out as soon as the producer finishes and the subscription keeps + // serving whatever a later cap raise releases (see the Rust publisher's Recv::Boundary). const sendEnd = async (): Promise => { endSent = true; - if (emitRange) { - return controls.response( - encodeSubscribeResponse(stream, { end: new SubscribeEnd(boundary()) }, this.version), - ); - } - return true; + if (!emitRange) return true; + return controls.response( + (async () => { + // A group that gives up before its stream opens is never counted. + if (countStreams) while (opening.size > 0) await Promise.all(opening); + const end = new SubscribeEnd(boundary(), streams); + await encodeSubscribeResponse(stream, { end }, this.version); + })(), + ); }; // One ranking for the whole subscription, shared by every group it serves. const priority = new Priority(track); + // Every group this subscription started serving, until its stream finishes or resets. + const groups = new Set>(); + // Cancels groups still queued for a stream slot. Only the subscriber leaving counts: // a track that ran out of groups still has to flush the ones already queued, and the // caller FINs the subscribe stream to say so. @@ -802,24 +828,39 @@ export class Publisher { case "error": throw recv.error; case "idle": + // Before lite-07, an end declared ahead of the live edge goes out as + // soon as it is known, while the remaining groups are still being + // produced. The lite-07 count is not final until those groups open. + if (!endSent && !countStreams && track.final() !== undefined) { + if (!(await sendEnd())) return; + continue; + } await waitForSubscription(controls, track); continue; case "boundary": // The producer finished but is still holding groups above the cap. // Declare the boundary, then wait for an update to release them. - if (!endSent) { + if (!endSent && !countStreams) { if (!(await sendEnd())) return; continue; } await waitForSubscription(controls, track); continue; - case "done": + case "done": { if (!endSent) { if (!(await sendEnd())) return; continue; } + // The FIN tells the subscriber every group is accounted for, so it waits + // until each group stream finished or reset. The subscriber leaving + // instead cancels whatever is still queued. + const drained = Symbol("drained"); + const end = await Promise.race([Promise.all(groups).then(() => drained), controls.ended]); + if (end instanceof Error) throw end; + if (end !== drained) return; finished = true; return; + } } const group = recv.group; @@ -846,7 +887,7 @@ export class Publisher { return; } - void this.#runGroup({ + const options: RunGroup = { sub, group, timescale, @@ -854,7 +895,20 @@ export class Publisher { unsubscribed, start: range.start, end: range.end, + }; + // `opening` settles when the stream opens so the lite-07 count can be sent. + // `groups` covers the serve too, so the FIN still waits for every stream to + // finish or reset, including one that has not opened yet. + const opened = this.#openGroup(options); + const task = opened.then(async (writer) => { + if (!writer) return; + streams += 1; + await this.#serveGroup(writer, options); }); + groups.add(task); + void task.finally(() => groups.delete(task)); + opening.add(opened); + void opened.finally(() => opening.delete(opened)); } } finally { if (!finished) unsubscribe(); @@ -906,7 +960,7 @@ export class Publisher { // relays re-serve with the same window. maxAge: info.maxAge, // Lite05 mandates per-frame timestamps. Advertise the track's timescale; - // `#runGroup` emits each frame converted to it. + // `#serveGroup` emits each frame converted to it. timescale: info.timescale, }); })(); @@ -935,7 +989,7 @@ export class Publisher { const datagram = await track.recvDatagram(); if (!datagram) return; // Track finished; #runTrack tears the subscription down. - // Convert the timestamp to the track's advertised timescale, matching #runGroup. + // Convert the timestamp to the track's advertised timescale, matching #serveGroup. const ts = Math.round(datagram.timestamp.as(timescale)); const body = new DatagramMessage(sub, datagram.sequence, ts, datagram.payload).encode(); @@ -985,15 +1039,13 @@ export class Publisher { } /** - * Serves one group on its own unidirectional stream. + * Opens the unidirectional stream for one group, or closes the group and resolves + * `undefined` when it cannot get one. * * @internal */ - async #runGroup(options: RunGroup) { - const { sub, group, timescale, priority, unsubscribed, start: startFrame, end: endFrame } = options; - // This model holds whole groups, so frame `startFrame` is always reachable unless - // the group ends first. Declaring it up front keeps the stream self-describing. - const msg = new GroupMessage({ subscribe: sub, sequence: group.sequence, frameStart: startFrame }); + async #openGroup(options: RunGroup): Promise { + const { group, priority, unsubscribed } = options; try { // The transport drains streams by send order, so this is what makes a high-priority // track (and a newer group within it) win the link when there isn't room for both. @@ -1007,79 +1059,88 @@ export class Publisher { cancel: unsubscribed, waitUntilAvailable: false, }); - if (!stream) { - group.close(new Error("no stream slot")); - return; - } - - // Everything past this point runs inside the cleanup scope, so a failure never leaves - // a finished group's stream being ranked. - try { - // A SUBSCRIBE_UPDATE re-ranks the subscription, so a group already on the wire - // follows it too rather than keeping a stale rank until it finishes. - priority.add(stream, group.sequence); + if (!stream) group.close(new Error("no stream slot")); + return stream; + } catch (err: unknown) { + group.close(error(err)); + return undefined; + } + } - await hooks.guardGroup( - group, - (async () => { - await stream.u53(0); // stream type - await msg.encode(stream, this.version); - })(), - ); + /** + * Serves one group on the stream {@link #openGroup} opened for it. + * + * @internal + */ + async #serveGroup(stream: Writer, options: RunGroup) { + const { sub, group, timescale, priority, start: startFrame, end: endFrame } = options; + // This model holds whole groups, so frame `startFrame` is always reachable unless + // the group ends first. Declaring it up front keeps the stream self-describing. + const msg = new GroupMessage({ subscribe: sub, sequence: group.sequence, frameStart: startFrame }); + // Everything past this point runs inside the cleanup scope, so a failure never leaves + // a finished group's stream being ranked. + try { + // A SUBSCRIBE_UPDATE re-ranks the subscription, so a group already on the wire + // 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); + })(), + ); - // Lite05+ prefixes every frame with a zigzag-delta timestamp at the track's - // advertised timescale; older drafts omit it. - const timestamps = supportsTrackStream(this.version); - let prevTs = 0n; - // Whether the cursor ever reached the requested start, which decides how the - // end of the group is read below. - let reached = startFrame === 0; - - for (;;) { - const read = await race([hooks.readGroupFrame(group), stream.closed]); - if (!read) { - // The group ended before the frame the subscriber asked to start - // at, so this publisher can't serve the range at all. FINning here - // would claim an empty group under that index; reset so it reads - // as the gap it is. - if (!reached) throw new Error(`group ended before frame ${startFrame}`); - break; - } + // Lite05+ prefixes every frame with a zigzag-delta timestamp at the track's + // advertised timescale; older drafts omit it. + const timestamps = supportsTrackStream(this.version); + let prevTs = 0n; + // Whether the cursor ever reached the requested start, which decides how the + // end of the group is read below. + let reached = startFrame === 0; - try { - // A group that ends exactly at the start is a valid, empty range. - if (read.sequence + 1 >= startFrame) reached = true; - // Frames below the requested start were excluded, and the receiver - // numbers what it gets from `startFrame`. - if (read.sequence < startFrame) continue; - if (endFrame !== undefined && read.sequence > endFrame) break; - - 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))); - prevTs = ts; - } + for (;;) { + const read = await race([hooks.readGroupFrame(group), stream.closed]); + if (!read) { + // The group ended before the frame the subscriber asked to start + // at, so this publisher can't serve the range at all. FINning here + // would claim an empty group under that index; reset so it reads + // as the gap it is. + if (!reached) throw new Error(`group ended before frame ${startFrame}`); + break; + } - await hooks.guardGroup(group, stream.u53(read.frame.payload.byteLength)); - await hooks.guardGroup(group, stream.write(read.frame.payload)); - } finally { - read.complete(); + try { + // A group that ends exactly at the start is a valid, empty range. + if (read.sequence + 1 >= startFrame) reached = true; + // Frames below the requested start were excluded, and the receiver + // numbers what it gets from `startFrame`. + if (read.sequence < startFrame) continue; + if (endFrame !== undefined && read.sequence > endFrame) break; + + 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))); + prevTs = ts; } - } - stream.close(); - group.close(); - } catch (err: unknown) { - const e = error(err); - stream.reset(e); - group.close(e); - } finally { - priority.remove(stream); + await hooks.guardGroup(group, stream.u53(read.frame.payload.byteLength)); + await hooks.guardGroup(group, stream.write(read.frame.payload)); + } finally { + read.complete(); + } } + + stream.close(); + group.close(); } catch (err: unknown) { const e = error(err); + stream.reset(e); group.close(e); + } finally { + priority.remove(stream); } } diff --git a/js/net/src/lite/subscribe.test.ts b/js/net/src/lite/subscribe.test.ts index 7f8f5e7fa5..afd4c7c0a6 100644 --- a/js/net/src/lite/subscribe.test.ts +++ b/js/net/src/lite/subscribe.test.ts @@ -152,10 +152,30 @@ test("SubscribeStart round-trips on draft-05", async () => { }); test("SubscribeEnd round-trips on draft-05", async () => { - const got = await responseRoundtrip(Version.DRAFT_05, { end: new SubscribeEnd(7) }); + // Type, length, group: no stream count before draft-07. + expect(await encode(Version.DRAFT_05, { end: new SubscribeEnd(7, 3) })).toEqual(new Uint8Array([1, 1, 7])); + const got = await responseRoundtrip(Version.DRAFT_05, { end: new SubscribeEnd(7, 3) }); expect("end" in got).toBe(true); if (!("end" in got)) throw new Error("expected end"); - expect(got.end.group).toBe(7); + expect([got.end.group, got.end.streams]).toEqual([7, 0]); +}); + +test("SubscribeEnd carries the stream count on draft-07", async () => { + expect(await encode(Version.DRAFT_07, { end: new SubscribeEnd(7, 3) })).toEqual(new Uint8Array([1, 2, 7, 3])); + const got = await responseRoundtrip(Version.DRAFT_07, { end: new SubscribeEnd(7, 3) }); + if (!("end" in got)) throw new Error("expected end"); + expect([got.end.group, got.end.streams]).toEqual([7, 3]); +}); + +test("SubscribeDrop is gone on draft-07", async () => { + const drop: SubscribeResponse = { drop: new SubscribeDrop({ start: 1, end: 3, error: 0 }) }; + await expect(encode(Version.DRAFT_07, drop)).rejects.toThrow(); + + // A draft-06 DROP is an unknown response type on draft-07. + const wire06 = await encode(Version.DRAFT_06, drop); + await expect(decodeSubscribeResponse(new Reader(undefined, wire06), Version.DRAFT_07)).rejects.toThrow( + "unknown subscribe response type: 2", + ); }); test("SubscribeDrop is type 0x2 on draft-05 and 0x1 on draft-04", async () => { diff --git a/js/net/src/lite/subscribe.ts b/js/net/src/lite/subscribe.ts index c5a5b37b78..3d1a1f2711 100644 --- a/js/net/src/lite/subscribe.ts +++ b/js/net/src/lite/subscribe.ts @@ -1,7 +1,7 @@ import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; -import { hasFrameBounds, hasGroupOrder, resolvesStart, Version } from "./version.ts"; +import { hasFrameBounds, hasGroupOrder, hasStreamCount, resolvesStart, Version } from "./version.ts"; /** * Encode the `Group Start` field shared by SUBSCRIBE and SUBSCRIBE_UPDATE. @@ -456,24 +456,35 @@ export class SubscribeEnd { /** The exclusive final group sequence: the first sequence that will never be produced. */ group: number; - constructor(group: number) { + /** + * The number of group streams the publisher opened for this subscription. + * Draft-07+ only; not on the wire before, where it decodes as 0. + */ + streams: number; + + constructor(group: number, streams = 0) { this.group = group; + this.streams = streams; } - async encode(w: Writer): Promise { + async encode(w: Writer, version: Version): Promise { return Message.encode(w, async (w) => { await w.u53(this.group); + if (hasStreamCount(version)) await w.u53(this.streams); }); } - static async decode(r: Reader): Promise { - return Message.decode(r, async (r) => new SubscribeEnd(await r.u53())); + static async decode(r: Reader, version: Version): Promise { + return Message.decode( + r, + async (r) => new SubscribeEnd(await r.u53(), hasStreamCount(version) ? await r.u53() : 0), + ); } } /// Indicates that one or more groups have been dropped. /// -/// Draft03+ only. +/// Draft-03 to Draft-06 only: Draft-07 counts group streams in SUBSCRIBE_END instead. export class SubscribeDrop { start: number; end: number; @@ -510,8 +521,9 @@ export class SubscribeDrop { * * The discriminator is version-dependent: * - Draft-03/04: `0x0` SUBSCRIBE_OK, `0x1` SUBSCRIBE_DROP. - * - Draft-05+: `0x0` SUBSCRIBE_START, `0x1` SUBSCRIBE_END, `0x2` SUBSCRIBE_DROP + * - Draft-05/06: `0x0` SUBSCRIBE_START, `0x1` SUBSCRIBE_END, `0x2` SUBSCRIBE_DROP * (SUBSCRIBE_OK was removed; acceptance is implicit). + * - Draft-07+: `0x0` SUBSCRIBE_START, `0x1` SUBSCRIBE_END (SUBSCRIBE_DROP was removed). */ export type SubscribeResponse = | { ok: SubscribeOk } @@ -548,12 +560,12 @@ export async function encodeSubscribeResponse(w: Writer, resp: SubscribeResponse await resp.start.encode(w); } else if ("end" in resp) { await w.u53(0x1); - await resp.end.encode(w); - } else if ("drop" in resp) { + await resp.end.encode(w, version); + } else if ("drop" in resp && !hasStreamCount(version)) { await w.u53(0x2); await resp.drop.encode(w); } else { - throw new Error("SUBSCRIBE_OK not supported for this version"); + throw new Error("subscribe response not supported for this version"); } break; } @@ -582,8 +594,9 @@ export async function decodeSubscribeResponse(r: Reader, version: Version): Prom case 0x0: return { start: await SubscribeStart.decode(r) }; case 0x1: - return { end: await SubscribeEnd.decode(r) }; + return { end: await SubscribeEnd.decode(r, version) }; case 0x2: + if (hasStreamCount(version)) throw new Error(`unknown subscribe response type: ${typ}`); return { drop: await SubscribeDrop.decode(r) }; default: throw new Error(`unknown subscribe response type: ${typ}`); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 67b4c34240..6b6f97f18b 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -9,6 +9,7 @@ import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from " import { groupBounds, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; import { type Reader, Stream } from "../stream.ts"; +import { TAIL_GRACE_MS, Tail } from "../tail.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; @@ -69,6 +70,13 @@ interface SubscribeEntry { // runGroup must consume to stay in sync; group streams block on it before decoding, // since a group's QUIC stream can race ahead of the subscribe stream. timescale: Signal; + // The group streams received, so the subscription can wait for the ones still owed + // after the publisher ends it. + tail: Tail; + // The first group the publisher serves (SUBSCRIBE_START) and the track's exclusive end + // (SUBSCRIBE_END), once it declares them. + start?: number; + end?: number; } /** @@ -531,7 +539,7 @@ export class Subscriber { const state: { stream?: Stream } = {}; const setup = this.#openSubscribe(state, msg, request, id, timescale); - let opened: { stream: Stream; producer: track.Producer }; + let opened: { stream: Stream; entry: SubscribeEntry }; try { opened = await withTimeout( setup, @@ -556,16 +564,20 @@ export class Subscriber { return; } - const { stream, producer } = opened; + const { stream, entry } = opened; + const producer = entry.track; try { // Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02 // don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there // and just wait on the stream/track like before. // - // On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream; - // drain them (we don't drive delivery off the resolved range) so the FIN is - // observed. Older drafts just wait for the stream to close. - const closed = supportsTrackStream(this.version) ? this.#drainResponses(stream) : stream.reader.closed; + // On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream until + // its FIN; older drafts just close it. Either way group streams can still be in + // flight, so the track ends only once the tail is accounted for. + const responses = supportsTrackStream(this.version) + ? this.#runResponses(stream, entry) + : stream.reader.closed; + const closed = responses.then(() => this.#settleTail(entry)); const subscriptionUpdates = this.version === Version.DRAFT_01 || this.version === Version.DRAFT_02 ? undefined @@ -573,8 +585,10 @@ export class Subscriber { // Terminal conditions (stream end, track close, a failed subscription update) settle at most // once; race them into one stable promise so the demand loop doesn't re-subscribe each pass. + // Updates stop quietly at the FIN, which can land before the responses ahead of it are + // decoded, so only their failure is terminal on its own. const terminal: PromiseLike[] = [closed, producer.closed]; - if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates); + if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates.then(() => closed)); const done = race(terminal); // Serve until a terminal condition fires or the last local subscriber leaves. The unused @@ -615,7 +629,7 @@ export class Subscriber { request: track.Request, id: bigint, timescale: Signal, - ): Promise<{ stream: Stream; producer: track.Producer }> { + ): Promise<{ stream: Stream; entry: SubscribeEntry }> { let producer: track.Producer; let drainOk = false; @@ -632,7 +646,8 @@ export class Subscriber { } // Register before opening SUBSCRIBE so a racing GROUP stream finds the entry. - this.#subscribes.set(id, { track: producer, timescale }); + const entry: SubscribeEntry = { track: producer, timescale, tail: new Tail() }; + this.#subscribes.set(id, entry); state.stream = await Stream.open(this.#quic); await state.stream.writer.u53(StreamId.Subscribe); @@ -646,7 +661,7 @@ export class Subscriber { } } - return { stream: state.stream, producer }; + return { stream: state.stream, entry }; } // Opens a TRACK stream, reads the single TRACK_INFO, and FINs. Lite-05+ only. @@ -792,21 +807,68 @@ export class Subscriber { } } - // Drains SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+). - // The resolved range is informational here; the producer already orders groups. - // Resolves (never rejects) on FIN or on the stream being reset out from under it, - // so it's safe to drop from a race without an unhandled rejection. - async #drainResponses(stream: Stream): Promise { - try { - for (;;) { - const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); - if (!resp) return; + // Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording + // the range the tail is accounted against. SUBSCRIBE_END declares the track's end right + // away, so a consumer learns it before the last groups arrive. Resolves on FIN or on the + // stream being reset out from under it; rejects only on a response that breaks the range. + async #runResponses(stream: Stream, entry: SubscribeEntry): Promise { + for (;;) { + let resp: Awaited>; + try { + resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); + } catch { + // Stream closed or reset; nothing more to read. + return; + } + if (!resp) return; + + if ("start" in resp) { + entry.start = resp.start.group; + } else if ("end" in resp) { + if (entry.end !== undefined) throw new ProtocolViolation("duplicate SUBSCRIBE_END"); + entry.end = resp.end.group; + // A local close can win the race with the response; there is nothing left to end. + if (entry.track.closed.peek() !== undefined) continue; + try { + entry.track.finishAt(entry.end); + } catch (err) { + throw new ProtocolViolation(`invalid SUBSCRIBE_END: ${reason(error(err))}`); + } + } else if ("drop" in resp) { + entry.tail.account(resp.drop.start, resp.drop.end + 1); } - } catch { - // Stream closed or reset; nothing more to drain. } } + // 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. + #settleTail(entry: SubscribeEntry): Promise { + const { tail, track } = entry; + // Already the smaller of the subscriber's and the track's max age. + const maxAge = track.subscription.peek()?.maxAge ?? Time.Milli.zero; + const grace = maxAge > 0 ? maxAge : TAIL_GRACE_MS; + + const complete = () => { + // 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. + if (entry.start === undefined) return true; + const bounds = groupBounds(track.subscription.peek()?.groups ?? {}); + const start = Math.max(entry.start, bounds.start); + const end = bounds.end === undefined ? entry.end : Math.min(entry.end, bounds.end); + return tail.covers(start, end); + }; + + return tail.settle(complete, grace, track.closed); + } + /** * Send SUBSCRIBE_UPDATE messages whenever the track's aggregate subscription changes. * @@ -891,11 +953,13 @@ export class Subscriber { return; } - const { track, timescale } = entry; + const { track, timescale, tail } = entry; const producer = new netGroup.Producer(group.sequence); - track.writeGroup(producer); + const read = tail.open(group.sequence); try { + track.writeGroup(producer); + // Block until the timescale is known; the group's stream can arrive before // TRACK_INFO (or implicit defaults) resolves it on the subscribe stream. let scale = timescale.peek(); @@ -916,7 +980,9 @@ export class Subscriber { let prevTs = 0n; for (;;) { - const done = await race([stream.done(), track.closed, producer.closed]); + // Only the group's own stream ends it: a track that closes first has already + // closed (or aborted) this group through its cache. + const done = await race([stream.done(), producer.closed]); if (done !== false) break; let timestamp: Time.Timestamp; @@ -940,6 +1006,8 @@ export class Subscriber { const e = error(err); producer.close(e); stream.stop(e); + } finally { + read(); } } @@ -1002,6 +1070,8 @@ export class Subscriber { if (!scale) return; const timestamp = new Time.Timestamp(dg.timestamp, Time.Timescale(scale)); + // A datagram's sequence is never owed a stream, so it never holds the tail open. + entry.tail.account(dg.sequence, dg.sequence + 1); entry.track.insertDatagram(dg.sequence, timestamp, dg.payload); } diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts new file mode 100644 index 0000000000..b74636c55c --- /dev/null +++ b/js/net/src/lite/tail.test.ts @@ -0,0 +1,219 @@ +import { expect, test } from "bun:test"; +import type { Consumer as GroupConsumer } from "../group.ts"; +import { randomHop } from "../hop.ts"; +import { createMockTransportPair } from "../mock.ts"; +import * as Path from "../path.ts"; +import { Reader, Stream } from "../stream.ts"; +import { Milli } from "../time.ts"; +import { Group as GroupMessage } from "./group.ts"; +import { StreamId } from "./stream.ts"; +import { + encodeSubscribeResponse, + Subscribe, + SubscribeDrop, + SubscribeEnd, + type SubscribeResponse, + SubscribeStart, +} from "./subscribe.ts"; +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. */ +function frame(payload: string): Uint8Array { + const bytes = new TextEncoder().encode(payload); + // Every field is under 64, so each is a one-byte varint. + return new Uint8Array([0, bytes.byteLength, ...bytes]); +} + +/** A group stream the test writes by hand, handed to the subscriber as if it arrived. */ +function groupStream(subscriber: Subscriber, sequence: number) { + let controller!: ReadableStreamDefaultController; + const readable = new ReadableStream({ start: (c) => (controller = c) }); + const handled = subscriber.runGroup( + new GroupMessage({ subscribe: 0n, sequence }), + new Reader(readable, undefined, undefined), + ); + return { + write: (payload: string) => controller.enqueue(frame(payload)), + finish: () => controller.close(), + reset: () => controller.error(new Error("reset")), + handled, + }; +} + +/** + * 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) { + const pair = createMockTransportPair(ALPN_05); + 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); + 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); + + return { + subscriber, + reader, + respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, VERSION), + fin: () => sub.writer.close(), + }; +} + +/** Read one group to its end, or the error it ended with. */ +async function readAll(group: GroupConsumer | undefined): Promise { + if (!group) throw new Error("no group"); + const payloads: string[] = []; + for (;;) { + const frame = await group.readString(); + if (frame === undefined) return payloads; + payloads.push(frame); + } +} + +/** Whether `promise` settles within `ms`. */ +async function settlesWithin(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const pending = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), ms); + }); + try { + return await Promise.race([promise.then(() => true), pending]); + } finally { + clearTimeout(timer); + } +} + +test("a group stream that arrives after the subscribe stream's FIN is delivered", async () => { + 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(); + + // 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(); + + 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"); + + 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(); +}); + +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(); + + const received = await reader.recvGroup(); + expect(await received?.readString()).toBe("0.0"); + group.reset(); + await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow(); + + // The track still ends cleanly: the group was accounted for, as a reset. + expect(await reader.recvGroup()).toBeUndefined(); + expect(await reader.closed).toBeNull(); +}); + +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(); + + // 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 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)); + 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) }); + 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(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(); + 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); +}); diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index fbaa1e871e..28d0470dd9 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -4,11 +4,12 @@ export const Version = { DRAFT_03: 0xff0dad03, DRAFT_04: 0xff0dad04, DRAFT_05: 0xff0dad05, - /// Lite-06. Adds announce ids: each active ANNOUNCE_BROADCAST implicitly assigns the next + /// Lite-06, advertised as the preferred WebTransport subprotocol. + /// Adds announce ids: each active ANNOUNCE_BROADCAST implicitly assigns the next /// ordinal, and ended/restart reference that id instead of repeating the path. /// Also adds frame-precise subscribe/fetch bounds and a GROUP frame offset. DRAFT_06: 0xff0dad06, - /// Lite-07, advertised as the preferred WebTransport subprotocol. + /// Work-in-progress lite-07, only negotiated when explicitly offered. /// Adds the ANNOUNCE_REQUEST hidden opt-in. DRAFT_07: 0xff0dad07, } as const; @@ -229,6 +230,25 @@ export function hasHidden(version: Version): boolean { } } +/** + * Whether SUBSCRIBE_END carries the subscription's group stream count, sent once every + * counted stream is open, in place of SUBSCRIBE_DROP. Added in lite-07. + */ +export function hasStreamCount(version: Version): boolean { + // Explicitly list older versions so future versions keep the lite-07+ behavior. + switch (version) { + case Version.DRAFT_01: + case Version.DRAFT_02: + case Version.DRAFT_03: + case Version.DRAFT_04: + case Version.DRAFT_05: + case Version.DRAFT_06: + return false; + default: + return true; + } +} + /// The WebTransport subprotocol identifier for moq-lite. /// Version negotiation still happens via SETUP when this is used. export const ALPN = "moql"; @@ -245,8 +265,10 @@ export const ALPN_05 = "moq-lite-05"; /// The ALPN string for Draft06. export const ALPN_06 = "moq-lite-06"; -/// The ALPN string for Draft07. -export const ALPN_07 = "moq-lite-07"; +/// The ALPN string for the work-in-progress Draft07. It is NOT in the default +/// WebTransport `protocols` list, so lite-07 is never advertised or negotiated by +/// default; a peer only reaches it when both sides explicitly offer this ALPN. +export const ALPN_07_WIP = "moq-lite-07-wip"; const VERSION_NAMES: Record = { [Version.DRAFT_01]: "moq-lite-01", @@ -255,7 +277,7 @@ const VERSION_NAMES: Record = { [Version.DRAFT_04]: "moq-lite-04", [Version.DRAFT_05]: "moq-lite-05", [Version.DRAFT_06]: "moq-lite-06", - [Version.DRAFT_07]: "moq-lite-07", + [Version.DRAFT_07]: "moq-lite-07-wip", }; export function versionName(v: Version): string { diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts new file mode 100644 index 0000000000..9daa4eaa56 --- /dev/null +++ b/js/net/src/tail.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "bun:test"; +import { accept, connect } from "./connection/index.ts"; +import * as Ietf from "./ietf/index.ts"; +import * as Lite from "./lite/index.ts"; +import { createMockTransportPair } from "./mock.ts"; +import { Producer as OriginProducer } from "./origin.ts"; +import * as Path from "./path.ts"; +import { TAIL_GRACE_MS } from "./tail.ts"; +import { Milli } from "./time.ts"; +import type { Ordered } from "./track.ts"; +import { wireOf } from "./wire.ts"; + +const url = new URL("https://localhost:4443/test"); + +// Long enough that no group is skipped as stale, and the moq-lite grace for the one group the +// IETF case never produces. +const MAX_AGE = Milli(100); + +async function session(protocol: string) { + const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ transport: pair.server, url, publish: origin.consume() }), + ]); + const broadcast = origin.createBroadcast(Path.from("test")); + broadcast.announce(); + const video = broadcast.createTrack("video"); + const remote = wireOf(client).consume(Path.from("test")); + const reader = remote.track("video").subscribe({ maxAge: MAX_AGE }).ordered(); + + return { + video, + reader, + close: () => { + broadcast.close(); + remote.close(); + client.close(); + server.close(); + }, + }; +} + +async function readAll(reader: Ordered): Promise { + const out: string[] = []; + for (;;) { + const next = await reader.readString(); + if (next === undefined) return out; + out.push(next); + } +} + +// moq-lite carries the end in SUBSCRIBE_END as soon as it is declared, so a subscriber +// learns it while the last group is still to come. +test.each([Lite.ALPN_05, Lite.ALPN_06])( + "%s: an end declared ahead of the live edge reaches the subscriber", + async (alpn) => { + const { video, reader, close } = await session(alpn); + try { + video.writeString("0"); + expect(await reader.readString()).toBe("0"); + video.writeString("1"); + expect(await reader.readString()).toBe("1"); + + video.finishAt(3); + expect(await reader.finished()).toBe(3); + + video.writeString("2"); + video.close(); + expect(await readAll(reader)).toEqual(["2"]); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(3); + } finally { + close(); + } + }, +); + +// moq-transport carries the end in an END_OF_TRACK object, so it survives a track that +// declared an end past the groups it produced, and the Stream Count lets the subscriber stop +// waiting for streams at once. +test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_20])( + "%s: END_OF_TRACK carries the declared end", + async (alpn) => { + const { video, reader, close } = await session(alpn); + try { + video.writeString("0"); + expect(await reader.readString()).toBe("0"); + + video.finishAt(4); + video.writeString("1"); + video.writeString("2"); + const ended = performance.now(); + video.close(); + + expect(await readAll(reader)).toEqual(["1", "2"]); + expect(await reader.closed).toBeNull(); + expect(reader.final()).toBe(4); + // Every counted stream arrived, so nothing waited out the grace. + expect(performance.now() - ended).toBeLessThan(TAIL_GRACE_MS); + } finally { + close(); + } + }, +); diff --git a/js/net/src/tail.ts b/js/net/src/tail.ts new file mode 100644 index 0000000000..e516f1ea08 --- /dev/null +++ b/js/net/src/tail.ts @@ -0,0 +1,125 @@ +import { type GetPromise, Signal } from "@moq/signals"; +import { Milli } from "./time.ts"; + +/** + * How long a subscriber waits for a group stream it cannot account for once the publisher + * has ended the subscription. + * + * A group reset before its header arrived leaves no trace, and QUIC does not order streams, + * so a stream opened before the end can still be in flight after it. This bounds the wait on + * IETF, and on moq-lite when the subscription has no max age to bound it with. + */ +export const TAIL_GRACE_MS = Milli(1000); + +// setTimeout truncates a longer delay to a signed 32-bit int and fires at once. +const MAX_TIMEOUT_MS = 2 ** 31 - 1; + +/** + * The group streams a subscription has received, so its end can wait for the ones still owed. + * + * A publisher ends a subscription before every group stream it opened has necessarily + * arrived. This records which sequences are accounted for (a stream's header arrived, or + * the publisher dropped them) and how many streams are still being read, and {@link settle} + * waits on them. + * + * @internal + */ +export class Tail { + // Disjoint, sorted, exclusive-end ranges of accounted sequences. A gap splits a range, so + // this stays as small as the number of gaps rather than the number of groups. + #accounted: [number, number][] = []; + // Group streams whose header arrived, and those still being read. + #streams = 0; + #active = 0; + #changed = new Signal(0); + + /** Group streams whose header arrived, whether they finished or were reset. */ + get streams(): number { + return this.#streams; + } + + /** + * Record a group stream whose header arrived. Returns the call that marks it read to + * its end, which is idempotent. + */ + open(sequence: number): () => void { + this.#streams += 1; + this.#active += 1; + this.#account(sequence, sequence + 1); + + let closed = false; + return () => { + if (closed) return; + closed = true; + this.#active -= 1; + this.#bump(); + }; + } + + /** Record sequences `[start, end)` as accounted for without a stream: dropped, or a datagram. */ + account(start: number, end: number): void { + this.#account(start, end); + } + + /** Whether every sequence in `[start, end)` is accounted for. */ + covers(start: number, end: number): boolean { + if (start >= end) return true; + // Ranges are merged on insert, so one range covers the span or none does. + return this.#accounted.some(([lo, hi]) => lo <= start && end <= hi); + } + + /** + * Wait until every stream is read to its end and `complete()` holds, or `grace` + * milliseconds pass with nothing left being read, or `closed` settles. + * + * A stream still being read is always waited for: a group ends on its own stream's FIN + * or reset, never because its track ended. The grace only gives up on streams that never + * arrived. + */ + async settle(complete: () => boolean, grace: Milli, closed: GetPromise): Promise { + let expired = false; + const timer = setTimeout( + () => { + expired = true; + this.#bump(); + }, + Math.min(grace, MAX_TIMEOUT_MS), + ); + try { + while (closed.peek() === undefined) { + if (this.#active === 0 && (expired || complete())) return; + await Signal.race(this.#changed, closed); + } + } finally { + clearTimeout(timer); + } + } + + #account(start: number, end: number): void { + if (start >= end) return; + + const merged: [number, number][] = []; + let lo = start; + let hi = end; + let placed = false; + for (const range of this.#accounted) { + if (range[1] < lo) { + merged.push(range); + } else if (hi < range[0]) { + if (!placed) merged.push([lo, hi]); + placed = true; + merged.push(range); + } else { + lo = Math.min(lo, range[0]); + hi = Math.max(hi, range[1]); + } + } + if (!placed) merged.push([lo, hi]); + this.#accounted = merged; + this.#bump(); + } + + #bump(): void { + this.#changed.update((revision) => revision + 1); + } +} diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index b5bdcd4af1..60836121ce 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1568,3 +1568,69 @@ test("malformed group bounds do not partially advance the cursor", () => { expect(track.tryRecvGroup()?.sequence).toBe(0); track.close(); }); + +test("finishAt refuses an end at or below a produced sequence", () => { + const producer = new TrackProducer("test"); + producer.writeGroup(new GroupProducer(5)); + + // Exclusive, so it must be above the highest produced sequence. + expect(() => producer.finishAt(5)).toThrow(); + expect(() => producer.finishAt(4)).toThrow(); + producer.finishAt(6); + + // A second end is refused, as is any group at or past the first. + expect(() => producer.finishAt(7)).toThrow(); + expect(() => producer.writeGroup(new GroupProducer(6))).toThrow(); + expect(() => producer.appendGroup()).toThrow(); + expect(() => producer.insertDatagram(6, Timestamp.now(), new Uint8Array(1))).toThrow(); + producer.close(); +}); + +test("finishAt declares an end ahead of the live edge without ending the track", async () => { + const producer = new TrackProducer("test"); + const track = producer.subscribe({ maxAge: Milli(60_000) }); + producer.writeGroup(new GroupProducer(5)); + const first = await track.recvGroup(); + expect(first?.sequence).toBe(5); + + producer.finishAt(8); + expect(track.final()).toBe(8); + expect(await track.finished()).toBe(8); + + // Not terminal: groups below the end still arrive, including a straggler. + const late = new GroupProducer(7); + late.close(); + producer.writeGroup(late); + const straggler = new GroupProducer(6); + straggler.close(); + producer.writeGroup(straggler); + expect((await track.recvGroup())?.sequence).toBe(6); + expect((await track.recvGroup())?.sequence).toBe(7); + + // A clean close keeps the declared end rather than the live edge. + producer.close(); + expect(await track.recvGroup()).toBeUndefined(); + expect(track.final()).toBe(8); + + // A late subscriber sees the same end. + expect(producer.subscribe().final()).toBe(8); +}); + +test("finished rejects when the track aborts or closes without an end", async () => { + const aborted = new TrackProducer("test"); + const pending = aborted.subscribe().finished(); + aborted.close(new Error("boom")); + await expect(pending).rejects.toThrow("boom"); + + const producer = new TrackProducer("test"); + const track = producer.subscribe(); + track.close(); + await expect(track.finished()).rejects.toThrow(); + + // A clean close is an end, so it resolves. + const clean = new TrackProducer("test"); + const reader = clean.subscribe(); + clean.appendGroup().close(); + clean.close(); + expect(await reader.finished()).toBe(1); +}); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index e94f0343fc..913d1ce70f 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -302,11 +302,12 @@ class TrackState { datagrams = new Signal([]); latest?: number; /** - * The exclusive final boundary, stamped when the producer closes cleanly: one past the - * highest sequence produced. Groups and datagrams share the namespace, so this can - * exceed `latest + 1` (which only tracks groups). Mirrors the Rust `final_sequence`. + * The exclusive final boundary, declared by {@link Producer.finishAt} or stamped by a + * clean close as one past the highest sequence produced. Groups and datagrams share the + * namespace, so this can exceed `latest + 1` (which only tracks groups). Mirrors the + * Rust `final_sequence`. */ - final?: number; + final = new Signal(undefined); closed = new Once(); update: Signal; /** Resolved once the producer commits the immutable properties. */ @@ -549,10 +550,8 @@ export class Producer { this.#prune(); for (const entry of this.#cache) this.#mirror(entry, sink); - if (closed !== undefined) { - sink.final = this.#state.final; - closeTrackState(sink, closed instanceof Error ? closed : undefined); - } + sink.final.set(this.#state.final.peek()); + if (closed !== undefined) closeTrackState(sink, closed instanceof Error ? closed : undefined); } // Recompute from every live sink because an update or close can narrow as well as widen @@ -669,11 +668,19 @@ export class Producer { this.#prune(); } - /** Append a new group with the next sequence number. */ - appendGroup(): GroupProducer { + // Refuse a write once the track is closed, or at or past its declared end. + #writable(sequence: number): void { if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + const final = this.#state.final.peek(); + if (final !== undefined && sequence >= final) { + throw new Error(`sequence ${sequence} is at or past the track's end ${final}`); + } + } + /** Append a new group with the next sequence number. */ + appendGroup(): GroupProducer { const sequence = this.#sequence; + this.#writable(sequence.next); const group = new GroupProducer(sequence.next); sequence.next = group.sequence + 1; this.#publish(group); @@ -690,7 +697,7 @@ export class Producer { * entry is already gone, so a long-evicted sequence is accepted as new. */ writeGroup(group: GroupProducer) { - if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + this.#writable(group.sequence); const existing = this.#cache.findIndex((entry) => entry.group.sequence === group.sequence); if (existing >= 0) { @@ -735,11 +742,11 @@ export class Producer { * relay preserving upstream numbering uses {@link insertDatagram}. */ appendDatagram(timestamp: Timestamp, payload: Uint8Array): number { - if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); - if (payload.byteLength > MAX_DATAGRAM_BYTES) throw new Error("datagram payload too large"); - const counter = this.#sequence; const sequence = counter.next; + this.#writable(sequence); + if (payload.byteLength > MAX_DATAGRAM_BYTES) throw new Error("datagram payload too large"); + counter.next = sequence + 1; this.#publishDatagram({ sequence, timestamp, payload }); return sequence; @@ -753,7 +760,7 @@ export class Producer { * apply. Most origin publishers want {@link appendDatagram} instead. */ insertDatagram(sequence: number, timestamp: Timestamp, payload: Uint8Array) { - if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + this.#writable(sequence); if (payload.byteLength > MAX_DATAGRAM_BYTES) throw new Error("datagram payload too large"); const counter = this.#sequence; @@ -763,12 +770,41 @@ export class Producer { this.#publishDatagram({ sequence, timestamp, payload }); } - /** Close the track and every subscriber, mirroring the abort to their groups. Idempotent. */ + /** + * Declare the track's exclusive end, possibly ahead of the live edge, mirroring the Rust + * `finish_at`. + * + * `final` is the first sequence that will never be produced, so a track whose last group + * is 89 finishes at 90. Groups and datagrams below it are still accepted; anything at or + * above it is refused. Unlike {@link close} it is not terminal: call `close()` once the + * remaining groups are written. Throws if the track is closed, already has an end, or + * `final` is at or below a sequence already produced. + */ + finishAt(final: number): void { + if (this.#state.closed.peek() !== undefined) throw new Error("track is closed"); + if (!Number.isSafeInteger(final) || final < 0) throw new RangeError(`invalid track end: ${final}`); + const declared = this.#state.final.peek(); + if (declared !== undefined) throw new Error(`track already ends at ${declared}`); + if (final < this.#sequence.next) { + throw new Error(`track end ${final} is below the next sequence ${this.#sequence.next}`); + } + this.#declareFinal(final); + } + + #declareFinal(final: number): void { + this.#state.final.set(final); + for (const sink of this.#sinks) sink.final.set(final); + } + + /** + * 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. + */ close(abort?: Error) { - // A clean close declares the final boundary; an abort ends without one. - if (abort === undefined && this.#state.closed.peek() === undefined) { - this.#state.final = this.#sequence.next; - for (const sink of this.#sinks) sink.final = this.#state.final; + if (abort === undefined && this.#state.closed.peek() === undefined && this.#state.final.peek() === undefined) { + this.#declareFinal(this.#sequence.next); } closeTrackState(this.#state, abort); clearTimeout(this.#pruneTimer); @@ -999,13 +1035,35 @@ export class Subscriber { } /** - * The track's exclusive final boundary, known once the producer closes cleanly: - * one past the highest sequence produced, or 0 for a track that produced none. - * Groups and datagrams share the sequence namespace, so this can exceed - * `latest() + 1`. Undefined while the track is live or after an abort. + * The track's exclusive final boundary: the end {@link Producer.finishAt} declared, which + * can be ahead of the live edge, or one past the highest sequence produced once the + * producer closes cleanly (0 for a track that produced none). Groups and datagrams share + * the sequence namespace, so this can exceed `latest() + 1`. Undefined until declared, + * and after an abort that declared none. */ final(): number | undefined { - return this.#state.final; + return this.#state.final.peek(); + } + + /** + * Resolve with the track's exclusive final boundary once it is known, mirroring the Rust + * `finished`. + * + * Resolves as soon as the end is declared, which may be ahead of the live edge, so it + * says nothing about every group having arrived: read until the cursor returns + * `undefined` for that. Rejects with the abort, or if the track closes without an end. + */ + async finished(): Promise { + for (;;) { + const final = this.#state.final.peek(); + if (final !== undefined) return final; + + const closed = this.#state.closed.peek(); + if (closed instanceof Error) throw closed; + if (closed !== undefined) throw new Error("track closed before its end was known"); + + await Signal.race(this.#state.final, this.#state.closed); + } } /** @@ -1158,9 +1216,15 @@ export class Subscriber { } // Package-internal readiness half of recvGroup. Each registration fires at most once, and - // the caller disposes the losers after whichever source wakes it. + // the caller disposes the losers after whichever source wakes it. A declared end wakes it + // too, so a publisher can forward the end before the live edge reaches it. #groupChanged(fn: () => void): Dispose { - const dispose = [this.#state.groups.changed(fn), this.#cursor.changed(fn), this.#state.closed.changed(fn)]; + const dispose = [ + this.#state.groups.changed(fn), + this.#cursor.changed(fn), + this.#state.closed.changed(fn), + this.#state.final.changed(fn), + ]; return () => { for (const close of dispose) close(); }; @@ -1410,6 +1474,11 @@ export class Ordered { return this.#subscriber.final(); } + /** Resolve with the track's exclusive final boundary once known; see {@link Subscriber.finished}. */ + finished(): Promise { + return this.#subscriber.finished(); + } + /** Limit subsequent reads to these groups and return this reader for chaining. */ withGroups(groups: Groups): this { this.setGroups(groups); diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 58873544ec..cd76a88781 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -195,3 +195,45 @@ for (const section of ["audio", "video", "text"] as const) { }); }); } + +for (const section of ["json", "binary"] as const) { + test(`catalog refuses zero or decreasing ${section} jitter without retaining it`, () => { + const catalog = new CatalogProducer(); + const tracks = (value: Catalog.Root) => { + const sectionValue = value[section]; + if (!sectionValue) throw new Error(`expected a retained ${section} section`); + return sectionValue.tracks; + }; + + expect(() => + catalog.mutate((value) => { + value[section] = { tracks: { data: { mode: "stream", jitter: Catalog.u53(0) } } }; + }), + ).toThrow("omit jitter"); + catalog.mutate((value) => { + expect(value[section]).toBeUndefined(); + }); + + catalog.mutate((value) => { + value[section] = { tracks: { data: { mode: "stream", jitter: Catalog.u53(100) } } }; + }); + for (const jitter of [Catalog.u53(50), undefined]) { + expect(() => + catalog.mutate((value) => { + tracks(value).data.jitter = jitter; + }), + ).toThrow("jitter cannot decrease"); + catalog.mutate((value) => { + expect(tracks(value).data.jitter).toBe(Catalog.u53(100)); + }); + } + + // A new track under the same name, after the old one is gone, starts over. + catalog.mutate((value) => { + delete tracks(value).data; + }); + catalog.mutate((value) => { + tracks(value).data = { mode: "stream", jitter: Catalog.u53(50) }; + }); + }); +} diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 7c45ec7962..3a40e3d1ae 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -24,12 +24,13 @@ export class CatalogProducer { mutate(fn: (catalog: Catalog.Root) => void): void { const value = structuredClone(this.#value); fn(value); - for (const section of ["audio", "video", "text"] as const) { - for (const [name, config] of Object.entries(value[section]?.renditions ?? {})) { - if (config.jitter === 0) throw new Error("omit jitter for a track flushed immediately"); - const previous = this.#value[section]?.renditions[name]?.jitter; - if (previous !== undefined && (config.jitter === undefined || config.jitter < previous)) { - throw new Error("jitter cannot decrease for an existing rendition"); + for (const [section, next] of Object.entries(jitters(value))) { + const previous = jitters(this.#value)[section]; + for (const [name, jitter] of Object.entries(next)) { + if (jitter === 0) throw new Error("omit jitter for a track flushed immediately"); + const before = previous?.[name]; + if (before !== undefined && (jitter === undefined || jitter < before)) { + throw new Error("jitter cannot decrease for an existing track"); } } } @@ -59,6 +60,19 @@ export class CatalogProducer { } } +/** Every track's advertised jitter, by section and then track name. */ +function jitters(catalog: Catalog.Root): Record> { + const pick = (tracks: Record | undefined) => + Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config.jitter])); + return { + audio: pick(catalog.audio?.renditions), + video: pick(catalog.video?.renditions), + text: pick(catalog.text?.renditions), + json: pick(catalog.json?.tracks), + binary: pick(catalog.binary?.tracks), + }; +} + // The wall time of `performance.now() === 0`, the zero every js/publish timestamp counts from. function pageClock(): Catalog.Clock { const wall = Math.round((performance.timeOrigin - Catalog.MOQ_EPOCH_UNIX_MILLIS) * 1000); diff --git a/js/watch/src/retention.test.ts b/js/watch/src/retention.test.ts index af0af0860e..3afeee39ea 100644 --- a/js/watch/src/retention.test.ts +++ b/js/watch/src/retention.test.ts @@ -1,16 +1,83 @@ -import { heapStats } from "bun:jsc"; -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { Container } from "@moq/hang"; import * as Moq from "@moq/net"; import { Time } from "@moq/net"; -import { Effect } from "@moq/signals"; +import { Effect, Signal } from "@moq/signals"; import { nextMedia, subscribeMedia } from "./media"; import { Sync } from "./sync"; +// These count what is still attached rather than the heap: `Bun.gc` scans the stack conservatively, +// so a stale pointer can pin thousands of dead cells and fail a heap count under load. + +// Reactions `run` attaches to promises still pending once it returns, which each hold until they +// settle. Recorded by hand: Bun's `mock.contexts` misses the engine's own calls from `Promise.race`. +async function pendingReactions(run: () => Promise): Promise { + const reacted: Promise[] = []; + const then = Promise.prototype.then; + const spy = spyOn(Promise.prototype, "then").mockImplementation(function (this: Promise, ...args) { + reacted.push(this); + return then.apply(this, args); + } as typeof then); + try { + await run(); + } finally { + spy.mockRestore(); + } + return reacted.filter((promise) => Bun.peek.status(promise) === "pending").length; +} + +// Signal listeners `run` registers that neither fired nor were disposed by the time it returns. +async function pendingListeners(run: () => Promise): Promise { + const listening = new Set(); + const changed = Signal.prototype.changed; + const spy = spyOn(Signal.prototype, "changed").mockImplementation(function ( + this: Signal, + fn?: (value: unknown) => void, + ) { + if (!fn) return (changed as () => Promise).call(this); + const token = {}; + listening.add(token); + const dispose = changed.call(this, (value) => { + listening.delete(token); + fn(value); + }); + return () => { + listening.delete(token); + dispose(); + }; + } as typeof changed); + try { + await run(); + } finally { + spy.mockRestore(); + } + return listening.size; +} + +// Frames sleep on the clock once each, so a sleep that keeps anything on a clock that never changes +// piles it up for the life of the player. +test("waits on a stable clock leave nothing behind", async () => { + const sync = new Sync({ delay: Time.Milli(10) }); + // Let the delay effect run before anchoring. + await new Promise((resolve) => setTimeout(resolve, 0)); + sync.received(Time.Milli.now()); + + const wait = async () => { + for (let round = 0; round < 10; round++) { + const now = Time.Milli.now(); + await Promise.all(Array.from({ length: 100 }, () => sync.wait(now))); + } + }; + expect(await pendingReactions(wait)).toBe(0); + expect(await pendingListeners(wait)).toBe(0); + + sync.close(); +}); + // The player path a decoder drives, one frame per group like AAC audio: the container consumer // reads each frame, the shared clock anchors on it, and presentation waits on the clock against -// the effect's teardown. Retention anywhere along it grows the heap with the frame count. -test("a long subscription through the player path keeps a flat heap", async () => { +// the effect's teardown. Retention anywhere along it grows with the frame count. +test("a long subscription through the player path leaves nothing behind", async () => { const broadcast = new Moq.Broadcast.Producer(); // A tiny publisher window, so the track's own replay cache stays flat too. const track = broadcast.createTrack("audio", { maxAge: Time.Milli(1) }); @@ -47,15 +114,13 @@ test("a long subscription through the player path keeps a flat heap", async () = await Promise.all(presenting); }; - const heap = () => { - Bun.gc(true); - return heapStats().objectCount; - }; - - await play(200); - const before = heap(); - await play(2000); - expect(heap() - before).toBeLessThan(1000); + // What stays is bounded by the track's window of open groups, not the frame count. + let reactions = 0; + const listeners = await pendingListeners(async () => { + reactions = await pendingReactions(() => play(2000)); + }); + expect(reactions).toBeLessThan(100); + expect(listeners).toBeLessThan(100); consumer.close(); effect.close(); diff --git a/js/watch/src/sync.test.ts b/js/watch/src/sync.test.ts index 2376f625e9..d8d18764b8 100644 --- a/js/watch/src/sync.test.ts +++ b/js/watch/src/sync.test.ts @@ -1,4 +1,3 @@ -import { heapStats } from "bun:jsc"; import { describe, expect, it } from "bun:test"; import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; @@ -99,25 +98,6 @@ describe("delay and buffer", () => { }); describe("wait", () => { - const promises = () => { - Bun.gc(true); - return heapStats().objectTypeCounts.Promise ?? 0; - }; - - it("leaves nothing behind on a stable clock", async () => { - const sync = new Sync({ delay: 10 as Time.Milli }); - await flush(); - sync.received(Time.Milli.now()); - - const before = promises(); - for (let round = 0; round < 10; round++) { - const now = Time.Milli.now(); - await Promise.all(Array.from({ length: 100 }, () => sync.wait(now))); - } - expect(promises() - before).toBeLessThan(100); - sync.close(); - }); - it("wakes a sleeping wait when the delay switches to instant", async () => { const delay = new Signal(10_000 as Time.Milli); const sync = new Sync({ delay }); diff --git a/justfile b/justfile index dee586e408..22f4d7084c 100644 --- a/justfile +++ b/justfile @@ -535,6 +535,7 @@ _check $BASE $TEST: just drafts check if [[ "$TEST" == true ]]; then just rs check-test --workspace --exclude moq-net-fuzz + just rs play else just rs check --workspace --exclude moq-net-fuzz fi @@ -640,6 +641,7 @@ _test $BASE: if [[ "$files" == ALL ]]; then just js test just rs test --workspace --exclude moq-net-fuzz + just rs play just py test elif [[ -n "$files" ]]; then just js test "$files" diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 2a5929954a..cbaf29ba13 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -792,8 +792,8 @@ def publish_json_snapshot( ``delta_ratio`` controls how aggressively deltas are emitted instead of full snapshots (0 disables deltas); ``None`` uses the binding's default. Set ``compression`` to DEFLATE-compress each group; the consumer must pass the same - flag. Advertise the track with :meth:`set_catalog_section` if consumers should - discover it. + flag. The track is advertised in the broadcast's catalog (``json.tracks.``) + until it finishes; a name the catalog already carries is refused. """ # Let the record supply delta_ratio's default rather than restating it here. config = ( @@ -807,7 +807,9 @@ def publish_json_stream(self, name: str, *, compression: bool = False) -> JsonSt """Publish a JSON stream track (lossless append-log). Every appended record is preserved and delivered in order. Set ``compression`` to - DEFLATE-compress the group; the consumer must pass the same flag. + DEFLATE-compress the group; the consumer must pass the same flag. The track is + advertised in the broadcast's catalog (``json.tracks.``) until it finishes; a + name the catalog already carries is refused. """ config = MoqJsonStreamConfig(compression=compression) return JsonStreamProducer(self._inner.publish_json_stream(name, config)) diff --git a/quest/m0/README.md b/quest/m0/README.md index eb67865d6d..a37d03ba03 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -94,9 +94,9 @@ 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 -- [Binary stats](/quest/m0/stats-binary/README.md) - an allocation-free stats tick and an on-demand FlatBuffers `.fb.z` flavor with a checked-in schema - [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 diff --git a/quest/m0/bbb-sd.md b/quest/m0/bbb-sd.md new file mode 100644 index 0000000000..65d632c84a --- /dev/null +++ b/quest/m0/bbb-sd.md @@ -0,0 +1,31 @@ +# [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/stats-binary/README.md b/quest/m0/stats-binary/README.md deleted file mode 100644 index 80fd327359..0000000000 --- a/quest/m0/stats-binary/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Allocation-free binary stats - -## Goal - -Draining the registry into stats frames allocates nothing per entry, and -producing a `.fb.z` frame keeps that property. Any consumer can request a -FlatBuffers flavor of every stats track (`publisher.fb.z`, `subscriber.fb.z`, -`sessions.fb.z`, per tier) that is smaller and cheaper to produce and read than -`.json.z`, backed by a checked-in schema that other languages can generate -readers from and that later fields extend without breaking old readers. The -JSON tracks stay, unchanged on the wire, for dashboards and debugging. - -Not here: a JS reader (the demo dashboard stays on JSON), replacing or -deprecating the JSON flavors, and any relay config switch. Both flavors are -served on demand, so an unrequested one costs nothing. - -## Plan - -Settled while planning: - -- **Why:** a typed contract for non-Rust consumers, fewer bytes, less CPU, and - less memory churn at scale. JSON's cost is mostly our pipeline: per-tick - `BTreeMap` frames, path clones, and merge-patch diffs through - `serde_json::Value`. The format change alone would not fix that. -- **FlatBuffers via planus:** the builder resets without freeing, strings live - in its buffer, reads are zero-copy views, and tables extend by appending - fields. We chose it over protobuf because generated protobuf types own their - strings and maps; this was decided over a hand-written protobuf codec. -- **One flavor, `.fb.z`:** a full snapshot per frame, with each group sharing - one DEFLATE window, so repeated bytes compress away without deltas. There is - no uncompressed `.fb`. -- **Extension-ready:** the schema leaves room for the client-stats extension - (a nested table on each entry), which - [schema](/quest/m1/qos/stats/schema.md) fills in when it lands. -- **The line lands on main:** the maintainer approved `Registry::report(&mut - Report)` as a published API break before the pending moq-net release. Merge - this line before #3928, the last breaking change before that release. - -The line owns the end-to-end check: a relay test that subscribes to -`.json.z` and `.fb.z`, pairs frames from the same tick (deterministically, -for example by driving one tick under paused time), and asserts that the -counters agree. - -## Quests - -- [FlatBuffers flavor](/quest/m0/stats-binary/flatbuffers.md) - moq-stats serves and reads `.fb.z` from a checked-in schema -- [Stats format page](/quest/m0/stats-binary/docs.md) - a doc/concept page for every stats track and both encodings - -## Related - -- [Client stats](/quest/m1/qos/stats/README.md) - the extension the schema must leave room for -- [Flate track wrapper](/quest/m2/flate/track.md) - the group-window discipline the `.fb.z` producer repeats diff --git a/quest/m0/stats-binary/docs.md b/quest/m0/stats-binary/docs.md deleted file mode 100644 index 499395cd88..0000000000 --- a/quest/m0/stats-binary/docs.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Stats format page - -## Goal - -A `doc/concept` page describes every stats broadcast and track: the path -layout, tiers, the three track kinds, both encodings (`.json.z` merge-patch -and `.fb.z` FlatBuffers), the counter semantics (cumulative, a decrease -starts a fresh segment), and how a consumer in another language generates a -reader from the `.fbs`. The relay config page and the moq-stats crate docs -link to it rather than repeating it. - -## Plan - -Start from the moq-stats crate docs and the `[stats]` section of -`doc/bin/relay/config.md`, and move the wire description there. Add the page -to the VitePress sidebar. - -## Required - -- [FlatBuffers flavor](/quest/m0/stats-binary/flatbuffers.md) - the encoding the page documents diff --git a/quest/m0/stats-binary/flatbuffers.md b/quest/m0/stats-binary/flatbuffers.md deleted file mode 100644 index 636b095740..0000000000 --- a/quest/m0/stats-binary/flatbuffers.md +++ /dev/null @@ -1,39 +0,0 @@ -# [L] FlatBuffers stats flavor - -## Goal - -`moq_stats::Producer` serves `[/]{publisher,subscriber,sessions}.fb.z` -on request next to the JSON tracks, and `moq_stats::Consumer` and the -aggregate read it, all from a checked-in `.fbs` schema. A benchmark shows -`.fb.z` beating `.json.z` on bytes, CPU, and allocations, for both producer -and consumer. If it -does not clearly win, abandon the quest and report the numbers. - -## Plan - -- The schema in `rs/moq-stats` is the contract: one table per `Traffic` and - `Presence`, keyed entries per frame, and room for the client-stats - extension as an optional nested table. Fields are only ever appended. - Generate with planus. Prefer generating in `build.rs` if planus supports - it cleanly; otherwise check in the output with a just recipe and a CI diff - check. -- The producer already collects each drain into per-track buffers kept - across drains, and `steady_drain_collects_without_allocating` holds that - path at zero allocations. Encode from those buffers into one planus - builder that resets every frame, and extend the test to cover it. Each group is one DEFLATE window, as - `.json.z` does today, with a full snapshot per frame and no deltas. -- `.fb.z` is a flavor suffix: it applies to every track name that takes - `.json.z` (default and named tiers, sessions, and the per-broadcast tracks - if [schema](/quest/m1/qos/stats/schema.md) lands first) and nothing - else. Extend `requested_track_shape` and the track-name helpers for it. Decide whether the helpers take a flavor enum instead of - `compressed: bool`, and weigh that break while the release is still pending. -- `Consumer` reads either flavor into the same frame types; decoding views - the inflated buffer without copying strings until a caller keeps one. -- Benchmark `.json.z` against `.fb.z` over broadcasts x tiers: bytes after - flate, encode and decode ns, and allocations on both sides. Put the numbers - in the PR. -- Update the stats section of `doc/bin/relay/config.md` and the moq-stats - crate docs (wire format) inline. - -Public API impact: additive on moq-stats unless the helper signatures change -(before the pending release). Wire impact: new on-demand tracks; the existing tracks are unchanged. 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 80800d1b8a..53be1396c0 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 @@ -28,10 +28,6 @@ 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. -## Required - -- [CLI import clock](/quest/m1/cli-import-clock.md) - built-in publishers populate the mapping applications read - ## Closes - [#2278](https://github.com/moq-dev/moq/issues/2278) - close this issue when the quest finishes diff --git a/quest/m1/README.md b/quest/m1/README.md index 50fb2a9660..f7b37eb577 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,13 +17,14 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests -- [Play harness](/quest/m1/play-harness.md) - moq play's tune-in, rendition-switch, and drain logic runs in per-PR CI without a device - [Missing fetch group](/quest/m1/fetch-missing-group.md) - HTTP /fetch answers 404 and `moq fetch` fails cleanly for a group the track lacks +- [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts -- [JS track tail](/quest/m1/js-track-tail.md) - a `@moq/net` subscriber delivers every group up to the declared end over lite and IETF, and JS publishers drain their groups before ending a subscription -- [lite-07 stream count](/quest/m1/lite-stream-count.md) - moq-lite-07 replaces SUBSCRIBE_DROP with a group-stream count in SUBSCRIBE_END, like moq-transport -- [Rust track tail](/quest/m1/rust-track-tail.md) - a moq-net subscriber accepts groups that arrive after the subscription's end, and PublishDone carries the real stream count +- [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them +- [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 - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` +- [JS bare FIN](/quest/m1/js-bare-fin.md) - a `@moq/net` subscriber aborts a track whose subscribe stream FINs before its declared end, like Rust +- [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` - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for - [Origin mount](/quest/m1/origin-mount.md) - a session sees a granted subtree from outside its root under a path inside it, read-only @@ -31,18 +32,16 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone -- [Data sections](/quest/m1/data-sections.md) - an application lists JSON and binary tracks in its own catalog section with its own per-track fields, published in one moq-mux call; data entries gain `bitrate` and `jitter` - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer -- [CLI import clock](/quest/m1/cli-import-clock.md) - fMP4, TS, and FLV imports publish on the shared broadcast clock across restarts -- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - CI drives native capture through clock edge cases and asserts the published timestamps - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included +- [Optional max age](/quest/m1/ietf-max-age.md) - max age is optional, set only by the publisher, and crosses moq-transport as MAX_CACHE_DURATION - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer - [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes -- [GStreamer encoder jitter](/quest/m1/gst-encoder-jitter-provenance.md) - only opted-in local encoder pads feed the flush clock +- [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers @@ -51,15 +50,15 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 - [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 -- [Reconnect test ports](/quest/m1/tokio-reconnect-ports.md) - moq-tokio reconnect and worker tests bind their own ports, with a `tcp_local_addr()` accessor +- [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 - [C++ through moq-ffi](/quest/m1/cpp/README.md) - generated C++ over moq-ffi with futures and expected-style errors, shipped as a tarball, vcpkg, and Conan, and adopted by the OBS plugin +- [moq-c](/quest/m1/moq-c.md) - libmoq ships as `moq-c`, beside `moq-cpp`, with its C header and library unchanged - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 -- [Opus descriptions](/quest/m1/audio-opus-input.md) - validate headers and honor codec clock, pre-skip, and gain -- [Capture formats](/quest/m1/audio-capture-format.md) - unsupported overrides refuse before device open and channel counts cannot wrap -- [NVENC teardown](/quest/m1/nvenc-teardown.md) - a rejected NVENC encode no longer hangs process shutdown +- [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain - [NVENC recovery](/quest/m1/nvenc-recovery.md) - partial initialization and rejected rate changes preserve valid state - [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode +- [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name - [Keyframe trigger](/quest/m1/keyframe-trigger.md) - an application can ask the built-in capture encoder for a keyframe - [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms, and client stats broadcasts from publishers and viewers - [Drain](/quest/m1/drain/README.md) - relay restarts drain sessions over GOAWAY instead of hard-dropping them @@ -76,8 +75,12 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [#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 +- [Sans-IO session bench](/quest/m1/bench-session.md) - a moq-net bench drives publisher, relay, and subscribers over the in-memory transport, swept over publishers, subscribers, and frame size +- [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 - [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 - [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 diff --git a/quest/m1/announce-compression.md b/quest/m1/announce-compression.md new file mode 100644 index 0000000000..cb0dc30bc7 --- /dev/null +++ b/quest/m1/announce-compression.md @@ -0,0 +1,106 @@ +# [L] Announce compression + +## Goal + +A moq-lite-07 ANNOUNCE_START can reuse the path of any live announcement on +its stream, and ANNOUNCE_START and ANNOUNCE_UPDATE can reuse the tail of any +live announcement's hop chain, so repeated names and relay paths stop costing +full bytes on every announce. Decoded routes are identical to the literal +encoding. Compression is mandatory for every lite-07 endpoint to decode, and +any encoder may still send everything literally. lite-06 and older are +unchanged. + +Non-goals: fewer announces (tree routing was dropped in favour of this), a +separate dictionary with its own bounds, and a production byte counter. + +## Plan + +### Wire + +A base is a live announcement on the same stream, named by its distance back +from the next unassigned Announce ID: `d` names `next - d`, so `1` is the +latest START, and `0` means none. Only the stream's ordered history is +consulted, so there is no new state, table limit, eviction, or reset rule: the +receiver already stores every live route, and a decoded route owns its values, +so a base ending later changes nothing. + +``` +ANNOUNCE_START { + Type (i) = 0x0 + Message Length (i) + Path Base (i), + Path Keep (i), // leading segments of the base's suffix + Route Prefix Suffix (s), // remaining segments, literal + Hops, + Warm Route Cost (i), + Cold Route Cost (i), +} + +ANNOUNCE_UPDATE { + Type (i) = 0x2 + Message Length (i) + Announce ID (i), + Hops, + Warm Route Cost (i), + Cold Route Cost (i), +} + +Hops { + Hop Base (i), + Hop Count (i), + Hop ID (i) ..., // literal leading hops, e.g. a new origin + Hop Keep (i), // trailing hops copied from the base's chain +} +``` + +- Paths share their head, so `Path Keep` counts whole leading segments of the + base's wire suffix (relative to the requested prefix). Segments, not bytes, + to keep the codec simple. +- Chains share their tail: the origin differs per publisher while the relays + behind it repeat. `Hop Keep` counts trailing entries of the base's wire hop + list (the one excluding ANNOUNCE_OK's Hop ID), appended after the literal + hops. The hop base is independent of the path base, since the best path + match can arrive over a different relay path. +- PROTOCOL_VIOLATION: a base that was never assigned or is already retired, a + non-zero keep with base 0, a keep larger than the base has, or a result that + breaks the existing path and hop rules (`MAX_PARTS`, 32 hops, repeated + non-zero Hop ID, duplicate live route). +- ANNOUNCE_END and ANNOUNCE_OK are unchanged. + +### Implementation + +- Rust (`rs/moq-net/src/lite/`): the codec is stateless today, so decoding + moves beside `PrefixRun` (subscriber) and encoding beside `AnnounceRun` + (publisher), which already hold the live set by Announce ID. The encoder + picks bases from two ordered indexes over its live announcements, one keyed + by path segments and one by reversed hop chain, so the longest shared head + or tail is a neighbour lookup. +- JS (`js/net/src/lite/`): decode everything, encode literally (both bases 0). +- Fuzz: `fuzz.rs` round-trips single messages; add a stream-level harness that + feeds message sequences through the stateful decoder. +- Update the MoQ-lite draft's ANNOUNCE_START and ANNOUNCE_UPDATE sections and + its lite-07 changelog, plus `doc/concept/moq-lite.md` where it describes the + format. + +### Verification + +- Codec tests for each violation, base reuse after ENDs, keep of the full + path/chain, anonymous (0) hops, and a new stream starting empty. +- Rust encoder against JS decoder (and literal JS against Rust), plus + `just test interop --all`. +- A benchmark of encoded announce bytes and encode/decode CPU against lite-06 + literal framing, swept over live routes and churn: a health-shaped workload + (`/private/channel_N/stream-health-` from several origins sharing + relay tails) and an all-unique control, whose overhead is the four zero + base/keep bytes per START. An earlier + prototype with separate path and chain dictionaries saved 69% on a similar + workload; report what this shape achieves. + +## Required + +- moq-lite-07 ships as `moq-lite-07-wip`, off by default, so its wire can still change ([#4148](https://github.com/moq-dev/moq/pull/4148)) + +## Related + +- [Relay memory](/quest/m1/relay-memory.md) - route state per relay, which + this leaves unchanged diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index acff3c3023..8c388a19bc 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -105,6 +105,8 @@ owned by that prerequisite, not duplicated in archive storage. - [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` +- [Paced replay](/quest/m1/archive/paced-replay.md) - a replay pushes its groups to live subscribers on one shared clock, so any live player plays it +- [Replay provenance](/quest/m1/archive/provenance.md) - a replay's catalog names its timeline, replay path, store URL, and format version - [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 diff --git a/quest/m1/archive/paced-replay.md b/quest/m1/archive/paced-replay.md new file mode 100644 index 0000000000..c17cd658e6 --- /dev/null +++ b/quest/m1/archive/paced-replay.md @@ -0,0 +1,27 @@ +# [M] Paced replay + +## Goal + +A live player plays a replayed recording unchanged: `moq import archive` +publishes each recorded track to SUBSCRIBE as well as FETCH, pushing its groups +when a shared clock reaches their media time, like `ffmpeg -re`. `export ts` +and web watch play a replay the way they play a live broadcast, and a viewer +who joins late joins mid-replay. + +## Plan + +- Today `moq_archive::Reader` publishes only the timeline track live and serves + media groups on FETCH through `track::Dynamic`, so a subscriber sees none. +- One clock per import, not per subscriber. It starts at the earliest recorded + timestamp across the selected tracks and every track paces against it, so + tracks stay in sync and every viewer sees the same moment. +- A timeline gap is skipped at pace (the clock keeps running). A growing + archive is followed as new timeline segments land. A finite archive ends + each track after its last group. +- FETCH keeps serving any advertised group, so DVR and HLS are unaffected. +- The API shape (a reader option vs a separate paced publisher) and the CLI + default follow the root API rules; report both in the PR. Update + `doc/bin/cli.md`. +- Test with paused time: record, replay paced, and assert a plain subscriber + gets every group in order at media pace, that a late subscriber starts at the + current group, and that two tracks stay aligned. diff --git a/quest/m1/archive/provenance.md b/quest/m1/archive/provenance.md new file mode 100644 index 0000000000..c766a0dc70 --- /dev/null +++ b/quest/m1/archive/provenance.md @@ -0,0 +1,18 @@ +# [S] Replay provenance + +## Goal + +The catalog a replay publishes carries the root `archive` entry the line +promises: the timeline track, the replay path, the store URL, and the format +version. A player or tool can tell a replay from the live source and find the +recording. + +## Plan + +- Check which of these fields `rs/hang` and `js/hang` already define. Add any + missing ones as optional fields in both, and in the Recording section of + `drafts/draft-lcurley-moq-hang.md`. +- Never advertise credentials: strip userinfo and query from the store URL, and + let the importer opt out of advertising the URL at all. +- `moq import archive` fills the entry. Test that a round trip through + `export archive` and `import archive` advertises it. diff --git a/quest/m1/audio-capture-format.md b/quest/m1/audio-capture-format.md deleted file mode 100644 index 3d392f05ef..0000000000 --- a/quest/m1/audio-capture-format.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Validate audio capture overrides before opening devices - -## Goal - -Unsupported sample-rate/channel overrides fail with the requested format and -device context, and channel counts never wrap while narrowing to the device API. - -## Plan - -capture::resolve currently overwrites the device default and casts u32 channels -to u16. A value such as 65537 becomes one. Supported ranges are already available -to device enumeration. Validate requested overrides and checked conversions -before opening the stream; make the existing documentation's hint/requirement -wording match the actual contract. Do not silently substitute another format. - -Test unsupported rates, 65537 channels, device ranges, valid default selection, -and backend open failures using fake capabilities in CI. No new capture API, -mixing, crop support, or device backend is required. - -Public API and wire: unchanged; previously malformed requests are refused. - -## Related - -- [Capture ergonomics](/quest/m2/capture-ergonomics.md) - crop and mixing planning remain independent diff --git a/quest/m1/audio-opus-input.md b/quest/m1/audio-opus-input.md deleted file mode 100644 index 9a6b0927b5..0000000000 --- a/quest/m1/audio-opus-input.md +++ /dev/null @@ -1,28 +0,0 @@ -# [M] Honor and validate Opus stream descriptions - -## Goal - -A valid Opus stream decodes using the codec's clock and declared pre-skip/gain; -an explicitly malformed or unsupported description is refused instead of -silently falling back to defaults. - -## Plan - -moq-audio's new_opus ignores description parse failures and uses the OpusHead -original-input-rate field to open a decoder. That metadata can be 44100 or -unknown even though it is not a supported decoder operating rate. Audit the -shared moq-mux Opus description parser as the source of truth, including -version, output gain, channel mapping, and truncation. - -Distinguish an absent optional description from a present invalid one. Preserve -supported mono/stereo and PCM behavior; do not implement surround here or guess -a mapping. Cover input-rate metadata independent of decode rate, unknown rate, -pre-skip, nonzero gain, malformed headers, and unsupported mapping/version with -real Opus fixtures in CI. Verify timestamps as well as decoded sample counts. - -Public API and wire schema: unchanged. Update the existing codec documentation -with supported/refused cases; header-parser changes stay focused on this path. - -## Related - -- [Opus surround](/quest/m1/audio-codecs/opus-surround.md) - adds supported mappings separately diff --git a/quest/m1/bench-ci.md b/quest/m1/bench-ci.md new file mode 100644 index 0000000000..3514730d8a --- /dev/null +++ b/quest/m1/bench-ci.md @@ -0,0 +1,54 @@ +# [M] Benchmark regressions in CI + +## Goal + +A pull request that touches a benched Rust crate, or anything it depends on, +gets a sticky comment comparing its Criterion benchmarks against the base. The +comment never fails the PR. A nightly run on `main` keeps a history of every +benchmark and fails when one regresses past a loose threshold against the +previous nightly's commit measured on the same runner, so the existing Alert +workflow posts it to Discord. + +Rust Criterion targets only. Browser and JS benchmarks stay with +[Browser benchmarks](/quest/m1/browser-benchmarks.md), and relay load stays +local under `just bench BASE`. + +## Plan + +Researched 2026-09: CodSpeed's instruction counting skips every +`iter_custom` bench (all of `moq-uring`, plus `moq-net` origin and track) and +can't see syscall time. Bencher compares a PR against history recorded on other +VMs, so its thresholds fire on runner noise. The chosen shape, free and without +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 + 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 + significant and beyond a noise floor you choose from A/A runs. Fork PRs + have a read-only token, so posting needs a `workflow_run` follow-up. +- Nightly: build the previous nightly's commit beside `main` on the same + runner and fail only on that paired comparison. Each hosted run is a fresh + VM, so the cross-run history is subject to the same noise as Bencher and + stays informational: record it with + [github-action-benchmark](https://github.com/benchmark-action/github-action-benchmark) + on a data branch, with its own alert disabled. The `moq-uring` socket + benches run only here, and skip loudly when the runner's kernel is too old. Add the workflow's name to + `alert.yml` if it isn't `Nightly`. +- Validate with an A/A run (the same commit twice) and a deliberately slowed + bench before trusting either signal. +- Document the comment, the trend, and local reproduction in + `bench/README.md`. + +## Required + +- [Thin justfiles](/quest/m1/tooling/justfiles.md) - owns the diff-to-crate impact map the PR job reuses + +## Related + +- [Benchmark comparisons](/quest/m1/performance-comparisons.md) - extends the same `bench/run.sh` with repeated paired rounds +- [Sans-IO session bench](/quest/m1/bench-session.md) - a low-noise end-to-end bench this job picks up +- [Bench coverage](/quest/m1/bench-coverage.md) - more targets for this job to track diff --git a/quest/m1/bench-coverage.md b/quest/m1/bench-coverage.md new file mode 100644 index 0000000000..05d5b3077a --- /dev/null +++ b/quest/m1/bench-coverage.md @@ -0,0 +1,28 @@ +# [M] Benchmarks for containers, catalog, auth, and path matching + +## Goal + +Hot paths that have no benchmark get Criterion targets, so CI tracks them: +`moq-mux` container import and export per frame, `hang` catalog encode, decode, +and update, `moq-auth` token verification, and `moq-pattern` path matching. + +## Plan + +- `moq-mux`: fMP4/CMAF and Annex-B import and export over checked-in or + generated fixtures, with throughput in frames and bytes. Keep fixture + generation out of the timed region. +- `hang`: catalog encode and decode swept over rendition count, plus the + per-update cost a publisher pays. +- `moq-auth`: JWT verification per connection, swept over algorithm and claim + size. The in-band token path gets its bench with + [In-band token](/quest/m1/auth/token-in-band.md), not here. +- `moq-pattern`: matching swept over pattern count and path depth. Coordinate + with [Path patterns](/quest/m1/path-patterns.md) so the matcher gets one + bench, not two. + +Anything that fans out gets a sweep over both axes. Name each target after +what it measures, so the CI comment reads without opening the file. + +## Related + +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - tracks these targets on PRs and nightly diff --git a/quest/m1/bench-relay.md b/quest/m1/bench-relay.md new file mode 100644 index 0000000000..596cddb10e --- /dev/null +++ b/quest/m1/bench-relay.md @@ -0,0 +1,24 @@ +# [L] Relay session benchmark through moq-relay + +## Goal + +The sans-IO session bench also runs through `moq-relay`'s own connection +handling, including auth, the cluster origin, and stats, over the in-memory +transport. Relay-layer costs then show up in benchmarks, not only the +`moq-net` model. + +## Plan + +`moq_relay::Connection` takes a `moq_tokio::server::Request`, which wraps a +concrete transport, so there is no seam for an in-memory session today. Find +the smallest change that lets the handler run over a generic `moq-net` +transport session without widening the public API. If the seam costs more +than the bench is worth, say so and stop. + +Reuse the scenario, the publisher and subscriber sweeps, and the delivery +accounting from the `moq-net` bench so the two results line up, and the +difference is the relay layer. + +## Required + +- [Sans-IO session bench](/quest/m1/bench-session.md) - supplies the scenario and harness this bench reuses diff --git a/quest/m1/bench-session.md b/quest/m1/bench-session.md new file mode 100644 index 0000000000..82aa479d22 --- /dev/null +++ b/quest/m1/bench-session.md @@ -0,0 +1,31 @@ +# [M] Sans-IO session benchmark in moq-net + +## Goal + +A Criterion bench in `moq-net` measures a full publishers to relay to +subscribers path with no sockets: M publishing clients, relay sessions that +forward through an origin the way `moq-relay` does, and N subscribing +clients, all over the in-memory transport. It sweeps publishers, subscribers, +and frame size independently, over lite and IETF, so a per-publisher, +per-subscriber, or per-frame cost shows as a slope and CI can compare it with +little noise. + +## Plan + +`rs/moq-net/tests/support/{mock,harness}.rs` already pairs sessions over an +in-memory WebTransport mock and runs the full handshake. Reuse it from the +bench, as `moq-uring`'s benches reuse their test support, rather than +exporting a mock from the crate. + +- Keep setup (handshakes, announce, the subscribe round trip) outside the + timed region. Time delivery of groups until every subscriber has received + every frame, and count delivered bytes so a skipped delivery can't look like + a speedup. +- Also time subscriber join, since a relay pays that per viewer. +- Report throughput in bytes and frames. +- Pick sweep points that finish quickly enough to run on every `moq-net` PR. + +## Related + +- [Relay session bench](/quest/m1/bench-relay.md) - the same shape through the real `moq-relay` handler +- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - tracks this bench on PRs and nightly diff --git a/quest/m1/binding-surface.md b/quest/m1/binding-surface.md index db6b94268c..87e3937bba 100644 --- a/quest/m1/binding-surface.md +++ b/quest/m1/binding-surface.md @@ -23,8 +23,6 @@ fallback settings. moq.pro's Python sidecar reads route sources from here. ## Required - Native decode delay (#3967) has merged -- Route source (#3972) has merged -- moq-ffi WebSocket fallback settings (#3961) have merged ## Related diff --git a/quest/m1/broadcast-close/README.md b/quest/m1/broadcast-close/README.md index 22d5bf99ba..65302ef805 100644 --- a/quest/m1/broadcast-close/README.md +++ b/quest/m1/broadcast-close/README.md @@ -41,6 +41,7 @@ removal. - [Rust close](/quest/m1/broadcast-close/rust.md) - moq-net gains `close()`, deprecates `finish`/`abort`/`is_finished`, and every Rust and JS caller moves over - [Binding close](/quest/m1/broadcast-close/bindings.md) - moq-ffi, libmoq, and every wrapper expose `close()` and deprecate `finish` +- [Kotlin end](/quest/m1/broadcast-close/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name - [Remove finish](/quest/m1/broadcast-close/remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause ## Related diff --git a/quest/m1/broadcast-close/kotlin-end.md b/quest/m1/broadcast-close/kotlin-end.md new file mode 100644 index 0000000000..4ecb51e805 --- /dev/null +++ b/quest/m1/broadcast-close/kotlin-end.md @@ -0,0 +1,18 @@ +# [XS] Kotlin end + +## Goal + +Kotlin can force-end a broadcast after `finish` is removed. Its +`MoqBroadcastProducer` exposes the moq-ffi `close()` as `end()`, because a +generated `close()` would collide with `AutoCloseable.close()`. + +## Plan + +- Rename the method for Kotlin only in `rs/moq-ffi/uniffi.toml`; every other + binding keeps `close()`. Kotlin's `close()` still only releases the handle. +- Test that `end()` ends the broadcast while a `dynamic()` handle is still + held. Document it in `doc/lib/kt`. + +## Required + +- [Binding close](/quest/m1/broadcast-close/bindings.md) - moq-ffi gains the `close()` this renames diff --git a/quest/m1/broadcast-close/remove.md b/quest/m1/broadcast-close/remove.md index 805896a6ee..4b62cc1db5 100644 --- a/quest/m1/broadcast-close/remove.md +++ b/quest/m1/broadcast-close/remove.md @@ -19,3 +19,4 @@ This is a published API break, so it targets `dev`. ## Required - [Binding close](/quest/m1/broadcast-close/bindings.md) - every binding already has `close()` to move to +- [Kotlin end](/quest/m1/broadcast-close/kotlin-end.md) - Kotlin keeps a forced end once `finish` is gone diff --git a/quest/m1/browser-benchmarks.md b/quest/m1/browser-benchmarks.md index 60c5dcb212..06043fac8d 100644 --- a/quest/m1/browser-benchmarks.md +++ b/quest/m1/browser-benchmarks.md @@ -7,7 +7,7 @@ Criterion targets and native relay load generator do not exercise. ## Plan -`rs/scripts/bench.sh::criterion_targets` discovers Cargo targets only. Existing JS unit +`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 diff --git a/quest/m1/cli-import-clock.md b/quest/m1/cli-import-clock.md deleted file mode 100644 index ccdf7f764b..0000000000 --- a/quest/m1/cli-import-clock.md +++ /dev/null @@ -1,34 +0,0 @@ -# [M] CLI imports publish on the broadcast clock - -## Goal - -`moq import` of fMP4, TS, and FLV publishes timestamps on the shared broadcast -clock, like native capture and `js/publish` already do, including source -restarts, late first frames, and real idle gaps. Today the imports publish -source PTS verbatim against a wall clock sampled at startup, so a TS feed with -a large starting PTS or a late first frame advertises the wrong wall time. - -## Plan - -Use `moq_mux::Clock` and `SourceMap` with the root catalog `clock`; this adds -no clock API or catalog field. Select each source's initial mapping once, -account for a delayed first frame, and translate source resets onto the same -monotonic clock while preserving real idle gaps. System-wall adjustments do not -retime a running broadcast or old archive records. Preserve allowed B-frame -ordering within a group. - -- fMP4 is passthrough, so translation must rewrite `tfdt`. -- A muxed source needs one mapping for all of its tracks, since interleaved - audio and video can step back further than `SourceMap::MAX_REORDER`. -- Keep conversion at the adapter boundary and refuse an unmappable source - explicitly. Discontinuity markers signal the existing playhead contract; - they do not replace the wall epoch. - -CI fixtures drive the import path, not only the clock helper: simultaneous -A/V, a late first frame, a restart to zero, a restart after idle, and retained -archive playback. Update the import docs. - -## Related - -- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - the same scenarios through native capture -- [GStreamer clock](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - separate source adapter diff --git a/quest/m1/cmaf-opus-dops.md b/quest/m1/cmaf-opus-dops.md new file mode 100644 index 0000000000..0456fb6f25 --- /dev/null +++ b/quest/m1/cmaf-opus-dops.md @@ -0,0 +1,20 @@ +# [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/data-jitter.md b/quest/m1/data-jitter.md index 76cee93321..e21e11ca3b 100644 --- a/quest/m1/data-jitter.md +++ b/quest/m1/data-jitter.md @@ -27,14 +27,17 @@ meaningless zero. 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 the `jitter` - [data sections](/quest/m1/data-sections.md) adds. + `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 [data sections](/quest/m1/data-sections.md) does for bitrate. + `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. @@ -45,4 +48,3 @@ entries. ## Required - [Jitter clock](/quest/m1/jitter-flush-clock.md) - defines the flush-lateness measurement and `delay` -- [Data sections](/quest/m1/data-sections.md) - adds the `jitter` field and the producers this feeds diff --git a/quest/m1/data-sections.md b/quest/m1/data-sections.md deleted file mode 100644 index df8802602b..0000000000 --- a/quest/m1/data-sections.md +++ /dev/null @@ -1,66 +0,0 @@ -# [M] moq-mux: publish data tracks into an application's own catalog section - -## Goal - -An application lists a JSON or binary track in its own root section, with -its own per-track fields next to the reading rules, and publishes it with one -`moq-mux` call that handles framing, compression, the entry's lifecycle, and -a detected `bitrate`. JSON and binary entries gain optional `bitrate` and -`jitter`, matching video and audio. - -The motivating case is MAVLink telemetry: the application's -`catalog["com.example.mavlink"].tracks[name]` flattens a `BinaryConfig` -(mode, compression, broadcast) beside `sysid`, `compids`, and `dialect`. One -entry per track, so nothing has to be kept in sync with a second listing. - -Non-goals: hang defines no MAVLink (or other application) section, and -`BinaryConfig`/`JsonConfig` gain no generic or opaque extension field. A -single `tracks` map holds every data track, so one type parameter would force -every application track kind into one enum. Generic data-track tooling does -not list tracks in an application section; that is the trade for one source of -truth. - -## Plan - -- **Producers.** `Producer::binary_snapshot`/`binary_stream` and - `json_snapshot`/`json_stream` accept any entry that implements - `RenditionConfig` and embeds a `BinaryConfig`/`JsonConfig`, exposed - through a small trait (a `&mut` accessor to the embedded config). The - producer fixes `mode`, applies `compression`, and writes the entry into - whichever section the `RenditionConfig` names; dropping the handle removes - it. The existing `binary::Config`/`json::Config` builders keep working - unchanged, so this generalizes a concrete parameter (RFC 1105 minor) and - lands on `main`. Name the trait by role; `catalog::Entry` is already the - consumer-side pairing of a name and config. -- **Consumers** need nothing new: `Entry::new(name, &mavlink.binary)` already - subscribes through `binary::Consumer`. -- **Bitrate/jitter.** Add optional `bitrate` (bits per second) and `jitter` - (`MillisCeil`, same meaning as video and audio) to `BinaryConfig` and - `JsonConfig` in `rs/hang`, `js/hang`, and the Binary and JSON sections of - `drafts/draft-lcurley-moq-hang.md`. Opt the data producers into bitrate - detection through the embedded config's `Estimate`. Extend `js/publish`'s - `CatalogProducer.mutate` check (nonzero, never lowered; - `js/publish/src/catalog.ts`), today audio and video only, to the `json` and - `binary` sections, with tests. `jitter` is set by the - publisher only here; detection is [data jitter](/quest/m1/data-jitter.md), - because flush lateness needs a source timestamp the producers do not take - yet. -- **Draft.** One line: application root sections SHOULD use a namespaced key - (e.g. reverse-DNS), since `text`, `json`, and `binary` already needed - lenient decoding for keys applications used first. -- **Docs.** A custom-section example in `doc/lib/rs/moq-mux.md` beside the - existing data-track one, and the `RenditionConfig` doc example switched to a - section that embeds a `BinaryConfig`. No new page. -- **Tests.** A custom section round-trips through a producer and a - `Catalog` consumer, drop removes the entry, a duplicate name is refused, - and bitrate is detected. The existing `binary::Config` path stays covered. - -Public API: additive in `moq-mux` (generalized data producer parameter, one -new trait) and `hang`/`@moq/hang` (two optional fields). Wire: two optional -catalog fields, additive. - -## Related - -- [Data jitter](/quest/m1/data-jitter.md) - detects the `jitter` this quest adds -- [MAVLink bridge](/quest/m2/teleop/mavlink.md) - the in-repo consumer of the same shape -- [Robot teleoperation primitive](/quest/m2/teleop/robot.md) - its telemetry section embeds data-track configs this way diff --git a/quest/m1/egress-rendition-pick.md b/quest/m1/egress-rendition-pick.md new file mode 100644 index 0000000000..b0b634a583 --- /dev/null +++ b/quest/m1/egress-rendition-pick.md @@ -0,0 +1,24 @@ +# [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 source](/quest/m1/transcode-source.md) - the same largest-rendition ranking for transcode input diff --git a/quest/m1/fetch-missing-group.md b/quest/m1/fetch-missing-group.md index ae1002961e..e144de69aa 100644 --- a/quest/m1/fetch-missing-group.md +++ b/quest/m1/fetch-missing-group.md @@ -16,7 +16,3 @@ cut-off body. call; propose it in the PR. - Test: a missing group is a 404 over HTTP and a non-zero `moq fetch` exit with no stdout; an existing group is byte-identical to today. - -## Required - -- `moq fetch` (#3965) has merged diff --git a/quest/m1/gst-encoder-jitter-provenance.md b/quest/m1/gst-encoder-jitter-provenance.md deleted file mode 100644 index ca6a4c387f..0000000000 --- a/quest/m1/gst-encoder-jitter-provenance.md +++ /dev/null @@ -1,23 +0,0 @@ -# [M] moq-gst: observe flush jitter only for local encoders - -## Goal - -A `moqsink` pad fed by a local encoder measures catalog jitter from the frame's -transport handoff, while file, pipe, demuxed, and network imports remain -clock-free. A GStreamer TIME segment and PTS alone cannot identify provenance: -`multifilesrc ! parsebin ! moqsink` supplies both. - -## Plan - -- Choose an explicit opt-in on the request pad (name and lifecycle to settle - with the maintainer). Default to imports, which retain batch/reorder estimates. -- For an opted-in pad, call the explicit codec importer flush observation after - a successful media write, with the mapped broadcast PTS and `Instant::now()`. - Refuse an invalid opt-in/timestamp combination instead of silently skipping. -- Exercise a local encoder pipeline and the existing looped MP4 import recipe; - prove only the opted-in path raises jitter. Document the opt-in in - `doc/bin/gstreamer.md` and update demo pipelines where they encode locally. - -## Related - -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - the flush measurement this pad feeds diff --git a/quest/m1/ietf-max-age.md b/quest/m1/ietf-max-age.md new file mode 100644 index 0000000000..cd7f99a48e --- /dev/null +++ b/quest/m1/ietf-max-age.md @@ -0,0 +1,43 @@ +# [M] Max age is optional and travels as MAX_CACHE_DURATION + +## Goal + +A track's max age is set only by its publisher. `None` means the publisher set +no limit, and the value crosses moq-transport as MAX_CACHE_DURATION, so it +survives an IETF hop the way it already survives moq-lite 05+. +`origin::Config::default_max_age` goes away. + +## Plan + +- `track::Info::max_age` becomes `Option`, defaulting to `None`. + `None` retains groups until the cache pool or `origin::Config::cache_duration` + evicts them. `Some(0)` keeps only the live edge. Mirror this in JS + (`maxAge` optional, drop `DEFAULT_MAX_AGE_MS`). +- Remove `track::DEFAULT_MAX_AGE` and `origin::Config::default_max_age`. A + track whose wire carries no max age gets `None`, never a local fallback. + Audit the callers that rely on the 5s default (moq-rtmp, moq-gst, moq-mux, + hang, JS lite tests) and give each an explicit value wherever it matters. + HLS/DASH egress asks for its window from the publisher; the old 5s was too + short for it anyway (`moq import` already sends 30s). +- IETF: MAX_CACHE_DURATION (0x04) is milliseconds, a message parameter through + draft 15 and a track property from draft 16. The subscriber reads it on + every draft, and absent means `None`, which is what the drafts mean by + omission. The publisher sends `Some(n)` as n and omits it for `None`, but + only on draft 17+: older moq-net peers reject 0x04 on draft 15 and trailing + properties on draft 16, so those drafts stay receive-only. Today Rust drops the property and JS + parses but never uses it. +- MAX_CACHE_DURATION is wall-clock and max age is media time with the newest + group always kept, so the mapping is approximate. Accept that rather than + modeling a second clock. +- moq-lite-07 (still WIP, off by default) makes TRACK_INFO's Max Age + optional: the value plus one, with 0 meaning none. Lite05/06 map `None` to + the largest varint in both directions. Update + `drafts/draft-lcurley-moq-lite.md` in the same PR. +- EXPIRES stays 0 on send and ignored on receive. It is subscription lifetime, + not retention. +- Breaks the published `track::Info` and `origin::Config`, so the PR retargets + to `dev`. Update `doc/concept/moq-lite.md` and the affected rustdoc and JS + docs in the same PR. +- Test: Rust-to-Rust and Rust-to-JS sessions over IETF and lite-07 carry + `None`, `Some(0)`, and a non-zero max age end to end, including through a + relay hop, and drafts 15 and 16 decode but never send it. Run `just test interop --all`. diff --git a/quest/m1/import-discontinuity.md b/quest/m1/import-discontinuity.md new file mode 100644 index 0000000000..c5c2fd6025 --- /dev/null +++ b/quest/m1/import-discontinuity.md @@ -0,0 +1,20 @@ +# [S] Import discontinuity + +## Goal + +A publisher that seeks or pauses tells its importer so, and the flush jitter +measurement restarts instead of counting the break. `moqsink` does it on a +seek, and C and FFI publishers can do it too. + +## Plan + +- `moq_mux::container::Producer::discontinuity()` already resets the flush + baseline (`catalog/estimate.rs`). `import::Track` gains a `discontinuity()` + that forwards to it, and the codec importers without one gain it too. Only + the baseline resets; advertised values are never lowered. +- `moqsink` calls it when a pad re-anchors after a flush or a new segment + (`rs/moq-gst/src/sink/pad.rs`). Test that a seek on an `encoder` pad does + not raise the advertised jitter. +- Expose it as `moq_publish_media_discontinuity` in libmoq and as + `discontinuity()` in moq-ffi and its hand-written wrappers, per the + Cross-Package Sync table. This is additive, so it lands on `main`. diff --git a/quest/m1/jitter-flush-clock.md b/quest/m1/jitter-flush-clock.md index 8c30505e14..e83e361955 100644 --- a/quest/m1/jitter-flush-clock.md +++ b/quest/m1/jitter-flush-clock.md @@ -33,26 +33,53 @@ media span of each emitted batch and advertise no `delay`. libmoq (so OBS), moq-ffi and its wrappers, and the `js/publish` encoders call it. The provisional PTS-gap floor is gone, and `moq_mux::Error::JitterDecreased` plus zero-as-absent text jitter enforce never-lower in Rust and JS. - `moq-gst` is split into [GStreamer encoder jitter](/quest/m1/gst-encoder-jitter-provenance.md). - What remains below is `delay` and the player. libmoq and moq-ffi expose - `flush` but no discontinuity, so a binding publisher that pauses and resumes - on a re-anchored PTS within the window would count the pause; add one when - such a caller appears. -- **Measurement.** `catalog::Estimator` gains an additive `flush(timestamp, - now)` observation next to the clock-free `write`. Lateness is - `now - timestamp`. Each rendition keeps its own baseline, the minimum - lateness over a sliding window (about 10 s) rather than the lifetime - minimum, so a media clock that drifts slower than wall time does not ratchet - forever. The broadcast baseline, on `catalog::Producer` and shared by every - rendition, is the minimum of those. `delay` is the rendition baseline minus - the broadcast baseline; `jitter` is `lateness - rendition baseline`. Each is - reported as its lifetime maximum. + `moq-gst` pads opt in with `encoder=true`; imports stay clock-free. + What remains below is `delay` and the player. A seek or pause resetting the + baseline from moqsink and the bindings is + [Import discontinuity](/quest/m1/import-discontinuity.md), including a + `moq-gst` encoder pad across a `PLAYING -> PAUSED -> PLAYING` cycle (running + time stops, the wall clock does not) and a flushing seek. +- **Measurement.** Lateness is `now - timestamp`, observed by the existing + `flush` calls, so no call site changes. Each rendition keeps its own + baseline, the minimum lateness over a sliding window (about 10 s), so a media + clock that drifts slower than wall time does not ratchet forever. The + broadcast baseline, on `catalog::Producer` and shared by every rendition + that flushes, is the minimum of those; the per-rendition `Baseline` epochs + must become one shared epoch for the subtraction to mean anything. `delay` is + the rendition baseline minus the broadcast baseline, reported as its lifetime + maximum. Renditions that never flush advertise no `delay`. - **Open:** lifetime maxima taken against a sliding baseline stop sharing an origin when the earliest rendition changes. If A starts at 0 and B at 200 ms, B keeps `delay: 200`; if A then drifts to 500 ms, A advertises 300 and `Sync` computes `300 - 200 = 100` while the tracks are 300 ms apart. - Settle a fixed common origin, coordinated rebasing, or no subtraction - before implementing. + Options: + - *No subtraction (recommended).* Keep the sliding baselines and never-lower, + and change the player rule to `max(delay + jitter)` over the subscribed + renditions, dropping `- min(delay)`. The subscribed renditions' true spread + is measured from an earliest subscribed baseline no earlier than the + broadcast baseline, so it never exceeds the largest advertised `delay`, + and the catalog alone can never under-buffer. The cost is over-buffering by + `min(delay)` when the broadcast's earliest rendition is not subscribed + (a video-only viewer of a broadcast whose audio leads by 200 ms pays + 200 ms). Dropping a slow track still lowers latency. The draft says a + consumer MUST NOT subtract `delay` values across renditions. + - *Fixed common origin.* Measure every `delay` from the broadcast's first + lateness. Exact subtraction, but common drift raises every rendition + together, so values grow without bound and the catalog republishes for the + life of the broadcast; the problem the sliding window exists to avoid. + - *Coordinated rebasing.* Advertise each `delay` as its current value against + the current broadcast baseline and let it fall. Exact, but `delay` gives up + never-lower, the catalog churns as baselines move, and the player must + shrink safely. + - *No `delay` field.* The player measures each subscribed track's own + arrival baseline and sizes by their spread. No wire change, and it also + covers gateway and ingest offsets, but a track's offset is unknown until its + first frames arrive, so subscribing to a slower rendition glitches once. + Every option also needs the player's reference to follow the earliest + subscribed track's current arrival rather than its lifetime minimum, since + `Sync.received` only ever lowers it; that is receiver-side and belongs with + the arrival minimum the [audio jitter target](/quest/m0/audio-jitter-target/README.md) + already expires. - A faster-than-real-time source flushes early; each frame becomes the new minimum and both stay at zero, which is correct for something that is not live. @@ -60,45 +87,24 @@ media span of each emitted batch and advertise no `delay`. renditions in `rs/hang`, `js/hang`, and `drafts/draft-lcurley-moq-hang.md`, serialized with `MillisCeil` and zero-as-absent like `jitter`. Additive: today's `jitter` never included a cross-track offset. Extend the draft's - never-lower rule to `delay`, and define both in terms of lateness. -- **Call sites:** the `moq-video` and `moq-audio` encode producers, the capture - path in `moq import capture`, `moq-gst`, and `libmoq` (so OBS). `js/publish` - mirrors the measurement in the video and audio encoders and replaces its - fixed `ceil(1000 / framerate)` and frame-duration hints. `container::Producer` - does not call it on its own. -- Replace the provisional PTS-gap floor so a decode-order sequence such as - `0, 120, 40, 80` ms does not permanently advertise its first 120 ms gap - when the reorder delay is only 80 ms. Preserve the never-lower rule for - measurements already advertised. -- **Enforce never-lower at the publisher**, not only by convention. - `js/publish/src/catalog.ts:25-29` already refuses a decrease and a zero for - audio and video jitter; Rust does not. Add a `moq_mux::Error` for a - decreased estimate (covering both fields) and return it from - `Rendition::set`, `Rendition::replace`, and `Rendition::estimate` - (`rs/moq-mux/src/catalog/tracks.rs`). Extend the `MillisCeil` serialization - (`rs/hang/src/catalog/millis.rs`) to `TextConfig` - (`rs/hang/src/catalog/text/mod.rs:124`), and mirror the zero-as-absent - normalization in `js/hang/src/catalog/text.ts` and the text section of - `js/publish/src/catalog.ts`. The `js/publish` check covers `delay` as well - as `jitter`, in every section that carries them. + never-lower rule to `delay` (unless rebasing wins), along with + `moq_mux::Error::JitterDecreased` and the `js/publish/src/catalog.ts` check. - **Player.** `Sync` registers each subscribed rendition's `delay` and - `jitter` and computes `max(delay + jitter) - min(delay)`; it recomputes when - a rendition registers, unregisters, or its catalog entry rises. When the - earliest subscribed rendition's `delay` rises, `Sync` re-anchors its - reference later by that amount instead of leaving it at the old earliest - arrival, or every other track under-buffers. Rename `Sync`'s own `delay` - output (the resolved playout total) so it does not collide with the field. + `jitter` and recomputes when a rendition registers, unregisters, or its + catalog entry rises. Rename `Sync`'s own `delay` output (the resolved + playout total) so it does not collide with the field. #3954 on the + [audio jitter target](/quest/m0/audio-jitter-target/README.md) line changes + what `register()` takes, so build on whichever lands first. - **Docs.** Update `doc/concept/audio-jitter.md`, the normative playout page, with the cross-track rule and the catalog floor it reads. -- **Tests** inject the clock. Cover: a batch flushed at its end reports the - batch as jitter, a constant offset reports as `delay` on the slower track - and nothing on the faster, a slow drift stays bounded, a decrease is refused - on every site, and `Sync` resizes on a subscription change and re-anchors on - a rising earliest `delay`. +- **Tests** inject the clock. Cover: a constant offset reports as `delay` on + the slower track and nothing on the faster, a slow common drift stays + bounded, the earliest rendition changing does not under-buffer, a decrease is + refused on every site, and `Sync` resizes on a subscription change. -Public API: additive `delay` field in `hang` and `@moq/hang`, a new -`moq_mux::Error` variant, `Sync` output rename in `@moq/watch`. Wire: one -optional catalog field. +Public API: additive `delay` field in `hang` and `@moq/hang`, the +decreased-estimate error extended to `delay`, `Sync` output rename in +`@moq/watch`. Wire: one optional catalog field. ## Related diff --git a/quest/m1/js-bare-fin.md b/quest/m1/js-bare-fin.md new file mode 100644 index 0000000000..212b0cdb53 --- /dev/null +++ b/quest/m1/js-bare-fin.md @@ -0,0 +1,18 @@ +# [S] JS bare FIN + +## Goal + +A `@moq/net` subscriber aborts a track whose subscribe stream FINs before the +publisher declared its end, over moq-lite and IETF, as Rust does since #4083. +A bare FIN is a failed request, never a clean end. + +## Plan + +- The drafts agree: draft-19 section 3.3.2 treats a FIN before the required + messages (PUBLISH_DONE) as a failure, and the moq-lite draft has a publisher + FIN only after SUBSCRIBE_END. +- Abort with the error Rust uses, so both languages report the same thing. Add + an interop case where a publisher FINs without declaring an end, in both + directions. + +The clean-end path this tightens landed in #4086. diff --git a/quest/m1/js-fetch-answer.md b/quest/m1/js-fetch-answer.md new file mode 100644 index 0000000000..0e9bc28b3b --- /dev/null +++ b/quest/m1/js-fetch-answer.md @@ -0,0 +1,32 @@ +# [S] JS lite fetch waits for the publisher's answer + +## Goal + +`js/net`'s lite `fetchGroup` resolves only once the publisher has answered: +the first response byte, or a FIN for an empty group. A missing group rejects +the fetch itself, and every coalesced caller sees the same rejection, instead +of receiving a group whose first `readFrame()` fails. A JS publisher that +cannot serve a group resets the stream with `NotFound`, not a generic error, +so a Rust or JS subscriber can tell a miss from a failure. + +## Plan + +- Rust already behaves this way since #4164, which waits in the lite + subscriber before accepting and rejects on reset. Mirror it: resolve after + the first response byte or an empty-group FIN, and reject on reset. Today + the fetch path returns its mirror before the response arrives; the stream + reader can already block until data or FIN and throw on reset. +- The publisher side throws a plain error for a local miss, which reaches the + wire as a generic reset code. Give it the `NotFound` code the Rust side uses. +- The IETF JS path refuses `fetchGroup` outright and is out of scope. +- Tests in the lite integration suite: a missing group rejects the fetch, a + coalesced second caller rejects too, an existing group is unchanged, and a + JS publisher's miss reaches a subscriber as `NotFound`. + +Public API: none; a behavior change in when `fetchGroup` settles. Wire: no +format change; a miss resets with the existing `NotFound` code instead of a +generic one. + +## Related + +- [#4164](https://github.com/moq-dev/moq/pull/4164) - the same fix in Rust diff --git a/quest/m1/js-track-tail.md b/quest/m1/js-track-tail.md deleted file mode 100644 index 8fb114d8b4..0000000000 --- a/quest/m1/js-track-tail.md +++ /dev/null @@ -1,111 +0,0 @@ -# [L] JS track tail - -## Goal - -A `@moq/net` subscriber delivers every stream-delivered group of a track up to -the publisher's declared end, over moq-lite and IETF alike, then ends cleanly. A group cut off -mid-read is never presented as complete. JS publishers finish their group -streams before they end a subscription, as the drafts require. A browser -publisher can also declare a track's end ahead of the live edge, and a browser -consumer can await it, matching Rust's `finish_at` and `finished()`. - -## Plan - -Today the tail of a track can be lost in the browser: - -- The lite subscriber discards SUBSCRIBE_END and closes the track for good - when the subscribe stream FINs. A group stream that arrives later is - dropped, and a group still being read is closed cleanly, so a truncated - group looks whole. `final()` is stamped from the highest group received, - not the declared end. The IETF subscriber does the same on PublishDone. -- The lite publisher FINs the subscribe stream while its group streams are - still being written (`void this.#runGroup`), against the moq-lite draft: - "The publisher closes the stream (FIN) only once every group from start to - end has been accounted for". The IETF publisher sends PublishDone the same - way. Rust drains its group tasks first. -- Even a compliant publisher races: QUIC does not order streams, so a group - below the boundary can arrive after the FIN. - -Build the primitive first, which absorbs #2318's remaining work: - -- `Track.Producer.finishAt(final)`, mirroring Rust's `finish_at`: the boundary - must exceed the highest produced sequence; groups below it are still - accepted and groups at or above it are refused. Unlike `close()`, it is not - terminal. -- Feed the boundary into the consumer's `final()`, so a remote clean end is - observable before the live edge reaches it. -- An awaitable `finished()` twin of `final()`, mirroring Rust: it resolves - with the boundary once known and rejects on abort. - -Then the subscribers, lite and IETF: - -- The declared end calls `finishAt`, and the subscribe stream's FIN no longer - closes the track. On moq-lite it is SUBSCRIBE_END. On IETF it depends on the - draft: draft-07's SUBSCRIBE_DONE carries a Final Group and Object, while on - drafts 14-22 PUBLISH_DONE carries no location, so the boundary comes from the - END_OF_TRACK object. Verify this against each draft. -- A PublishDone whose status is an error (INTERNAL_ERROR or similar) aborts - the track with it; only a clean status (TRACK_ENDED or equivalent) ends it - cleanly. -- Keep accepting groups below the boundary until each is accounted for: - completed, reset, dropped via SUBSCRIBE_DROP, or covered by the stream - count. Then end cleanly. -- A group reset before its header arrived can never be accounted for, so - after the boundary is known the subscriber gives up on missing groups after - a grace, then ends cleanly, skipping them like any stale group: - - moq-lite: the subscription's effective `max_age` (the smaller of the - subscriber's and the track's), used as a wall-clock duration. This is the - wrong clock on purpose, as a stopgap: `max_age` measures presentation-time - drift and elsewhere never adds wall-clock delay. Define a fallback for a - subscription and track with no `max_age`, since a zero grace reintroduces - the race. The correct fix is the publisher sending SUBSCRIBE_DROP for - every group it reset or never finished, which accounts for every group - with no timer; reliable reset is probably better still. - - IETF: a bounded wall-clock wait, since moq-transport has no per-group drop - and itself says subscribers SHOULD use a timeout here. Settle its value. - - The reliable-reset quest removes both waits once a reset group stream - keeps its header. -- The complete-tail guarantee covers groups delivered on streams only. - Datagrams are unreliable by design and a lost one leaves no signal, so the - subscriber never waits for a datagram-delivered sequence, and a - datagram-only subscription ends at the boundary at once. -- A group ends on its own stream's FIN or reset, never because its track - ended. A reset aborts the group. - -And the publishers: - -- Lite FINs the subscribe stream only after every group stream it started has - finished or been reset. -- IETF sends PublishDone after the same drain, with the real number of data - streams it opened instead of the hardcoded 0. -- IETF on drafts 14-22 writes the END_OF_TRACK object at the boundary; today - it writes only GROUP_END (`js/net/src/ietf/object.ts`), so no in-repo - subscriber could learn the end. Assert the boundary end to end. -- A received stream count is a hint: stop waiting once that many streams are - accounted for, but accept a late stream below the boundary within the grace. - A published peer's 0 then behaves like today's lite FIN plus the grace. - -Confirm Stream Count's meaning for the implemented IETF drafts (07, 14-22) -before relying on it, and bring any draft that disagrees back as a question. - -Reproduce each race deterministically before fixing it. The mock transport -(`js/net/src/mock.ts`) delivers streams in creation order, so drive the -publisher by hand: answer TRACK_INFO, write SUBSCRIBE_START, open a group -stream and write part of it, then write SUBSCRIBE_END and FIN, then finish -the group or open another one. `gateWrites` in -`js/net/src/lite/publisher.test.ts` holds a stream's writes. Cover a late -group, a mid-read group, a reset group, a missing group resolved by the -grace, and both publishers draining. - -Additive on `@moq/net`, so it targets `main`. The wire fix to the publishers -and to stream_count follows the published drafts, which already required it. - -## Closes - -- [#2318](https://github.com/moq-dev/moq/issues/2318) - close this issue when the quest finishes - -## Related - -- [Rust track tail](/quest/m1/rust-track-tail.md) - the same rule in moq-net, so local and remote readers match -- [Session death error](/quest/m1/session-death-error.md) - the other way a JS track ends wrong: cleanly instead of with the error -- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - removes the grace once a reset group stream keeps its header diff --git a/quest/m1/lite-count-settle.md b/quest/m1/lite-count-settle.md new file mode 100644 index 0000000000..0ff1f18a71 --- /dev/null +++ b/quest/m1/lite-count-settle.md @@ -0,0 +1,29 @@ +# [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/lite-stream-count.md b/quest/m1/lite-stream-count.md deleted file mode 100644 index 30f8a05363..0000000000 --- a/quest/m1/lite-stream-count.md +++ /dev/null @@ -1,43 +0,0 @@ -# [M] moq-lite-07 counts group streams instead of dropping groups - -## Goal - -On moq-lite-07, a subscriber knows a subscription has delivered everything -once it has seen as many group streams as the publisher opened, the way -moq-transport's PUBLISH_DONE Stream Count works. SUBSCRIBE_DROP is gone from -lite-07: a group the publisher skipped or never opened is simply not counted, -so nothing has to name it. Published versions (lite-01 to -06) keep decoding -SUBSCRIBE_DROP unchanged. - -Where reliable reset is negotiated, the count is exact and the subscriber -waits for nothing else. Where it is not (browsers today), a stream reset -before its header arrived is still invisible, so the track-tail grace stays. - -## Plan - -- Wire: SUBSCRIBE_END gains `Stream Count`, the number of group streams the - publisher opened for this subscription. The publisher sends it once every - group stream below the end has been opened (not finished), like - PUBLISH_DONE, so the boundary arrives slightly later than today. Remove - SUBSCRIBE_DROP and its type from lite-07 and reword the Subscribe Stream - section: the FIN follows once every counted stream has finished or been - reset. lite-07 is unpublished, so this changes it in place; update - `drafts/draft-lcurley-moq-lite.md` and its changelog. -- Rust and JS publishers count the streams they open per subscription and - send the count; a relay counts its own downstream streams, never forwarding - the upstream count. -- Subscribers on lite-07 stop waiting once the count is reached, accepting a - late stream below the end within the grace. On lite-05 and -06, the - DROP accounting from JS track tail (#4086) stays as it is. -- Tests in both languages: a late stream after SUBSCRIBE_END, a skipped group - that is never counted, a reset stream, and a count of zero. Add a Rust-JS - interop case. - -This lands before lite-07 is published. Rust has never sent or acted on -SUBSCRIBE_DROP, so [Rust track tail](/quest/m1/rust-track-tail.md) builds its -lite accounting on the count rather than on drops. - -## Related - -- [Rust track tail](/quest/m1/rust-track-tail.md) - builds on this count for moq-lite -- [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/loc-duration-marker.md b/quest/m1/loc-duration-marker.md index 5d0f71e01b..1151d24487 100644 --- a/quest/m1/loc-duration-marker.md +++ b/quest/m1/loc-duration-marker.md @@ -3,18 +3,13 @@ ## Goal LOC video groups end with the empty frame that closes their last frame's -duration, the same contract the legacy container carries, once every released -LOC video consumer skips it. +duration, the same contract the legacy container carries. ## Plan The consumer-side skip has landed in `rs/moq-mux/src/container/loc` and -`js/loc`; endpoint recognition is configured for media tracks so empty data -frames remain data. LOC producers are still silent, because a released LOC consumer -submits an empty payload to the decoder. When the bullet below clears, have -the LOC producers write the marker at `cut` and `finish` exactly as the -legacy producer does (`Container::finish_group` on `loc::Wire(Kind::Video)`), and extend the same tests. - -## Required - -- A release of `moq-mux` and `@moq/loc` whose video consumers skip an empty LOC payload has shipped +`js/loc`, and shipped in `moq-mux` 0.10.3 and `@moq/loc` 0.2.3. Endpoint +recognition is configured for media tracks, so empty data frames remain data. +Have the LOC producers write the marker at `cut` and `finish` exactly as the +legacy producer does (`Container::finish_group` on `loc::Wire(Kind::Video)`), +and extend the same tests. diff --git a/quest/m1/moq-c.md b/quest/m1/moq-c.md new file mode 100644 index 0000000000..608a5bfcb7 --- /dev/null +++ b/quest/m1/moq-c.md @@ -0,0 +1,33 @@ +# [M] libmoq becomes moq-c + +## Goal + +The C bindings ship as `moq-c`, matching `moq-cpp` and the other bindings: the +crate, its directory, release tags, CMake package, and pkg-config file all use +the name. C code and link lines do not change: the header stays `moq.h` and the +library file keeps its current name. The published `libmoq` crate gets one last +release that points users at `moq-c`. + +## Plan + +- Rename the crate `libmoq` to `moq-c` (`rs/libmoq` to `rs/moq-c`), its release + tags from `libmoq-v*` to `moq-c-v*`, and its workflow. The CMake package + becomes `find_package(moq-c)` and the pkg-config file `moq-c.pc`. The C++ + package is already `moq-cpp` (#4187), so the two install side by side. +- Keep the `[lib] name` so the library file and `moq.h` are unchanged. +- A published package rename is a break, so this lands on `dev`. Release + tooling (release-plz, alert and nightly workflows, cachix) must follow the + new name and tag; check every workflow that names libmoq. +- Update every consumer and reference: `cpp/obs` (`find_package`), interop + clients, `doc/lib/c`, `doc/bin/obs.md`, the root `CLAUDE.md` Cross-Package + Sync table, and quests that name libmoq. Grep the whole repository. +- Publish a final `libmoq` release whose README and description point at + `moq-c`, then stop publishing it. The maintainer cuts releases; the PR only + prepares it. + +Public API: the C ABI is unchanged; the crate, package, and tag names change. +Wire: none. + +## Related + +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - the `moq-cpp` package this name mirrors diff --git a/quest/m1/native-clock-fixtures.md b/quest/m1/native-clock-fixtures.md deleted file mode 100644 index 88b158e028..0000000000 --- a/quest/m1/native-clock-fixtures.md +++ /dev/null @@ -1,19 +0,0 @@ -# [S] Native capture proves the broadcast clock in CI - -## Goal - -Per-PR CI drives the native video and audio capture publishers through clock -edge cases and asserts the published timestamps: simultaneous A/V, a late -first frame, a restart to zero, a restart after idle, a system-wall -adjustment, and retained archive playback. Anything they catch is fixed here. - -## Plan - -Native video already maps the device timeline onto `catalog.clock()` at open, -and native audio stamps arrival on it. The fixtures exercise publisher -integration with a synthetic device source and an injected clock, rather than -only the clock helper. No new clock API or catalog representation. - -## Related - -- [CLI import clock](/quest/m1/cli-import-clock.md) - the same scenarios through `moq import` diff --git a/quest/m1/nvenc-recovery.md b/quest/m1/nvenc-recovery.md index c65cb61035..201b96cbc7 100644 --- a/quest/m1/nvenc-recovery.md +++ b/quest/m1/nvenc-recovery.md @@ -8,7 +8,9 @@ panic, and a rejected rate change leaves the last accepted settings intact. ## Plan Registration rollback and non-panicking destructors landed with the ownership -work (#3834, #3835, #3838). Session::reconfigure still mutates retained +work (#3834, #3835, #3838). A submission whose wait fails abandons its +buffers: the raw handles leak, but the encoder reference is released so the +session is still destroyed. Session::reconfigure still mutates retained bitrate/VBV fields before the driver accepts the change; a rejected zero-rate update can corrupt the basis of the next proportional update. diff --git a/quest/m1/nvenc-teardown.md b/quest/m1/nvenc-teardown.md deleted file mode 100644 index cde5459bee..0000000000 --- a/quest/m1/nvenc-teardown.md +++ /dev/null @@ -1,26 +0,0 @@ -# [S] A failed NVENC encode does not hang shutdown - -## Goal - -After NVENC rejects an encode (P7 with high-quality tuning returns -`InvalidParam`), the process shuts down promptly. Today it hangs on exit. - -## Plan - -Reproduce it first with the `encode-presets` example from #4099. Suspects, -from reading the code: `encode::Sink` runs NVENC on the `moq-video-encode` -thread, and `Worker::drop` joins it. That thread then drops the encoder, and -`Session::drop` calls a synchronous end-of-stream `encode_picture`. A -`Pending` that did not finish runs a blocking `lock_bitstream`. Either one can -wedge on a session the driver already refused, and the join then waits -forever. - -Fix the cause, for example by skipping end-of-stream on a session whose -encode failed. A timeout on the join doesn't count as a fix. Add a regression -test that forces the failing configuration on hardware where NVENC exists and -asserts teardown returns. Wire it into the nightly GPU lane if there is one; -otherwise say where it runs. - -## Related - -- [NVENC recovery](/quest/m1/nvenc-recovery.md) - the other NVENC failure path, rate changes and partial init diff --git a/quest/m1/path-patterns.md b/quest/m1/path-patterns.md index 4900d1beef..fff74556a5 100644 --- a/quest/m1/path-patterns.md +++ b/quest/m1/path-patterns.md @@ -59,12 +59,12 @@ trips, and the moq-net fuzz harness's `pattern` target prevent semantic drift at the authorization boundary. Matching is linear and inherits `Path::MAX_PARTS` (32), which also bounds residual expansion. -Grants and claims carry no version. `moq-auth` reads patterns only: `foo` means exactly `foo`, a -subtree is `foo/**`, and an unversioned prefix credential fails verification. -Translating the prefix credentials a deployment already issued is that -deployment's job at its own edge for a deprecation window, which is what -moq.pro (downstream) does. A wire message that carried prefixes keeps them on -the protocol versions that defined them; only new versions carry patterns. +Grants and claims carry no version. `moq-auth` grants are patterns: `foo` +means exactly `foo` and a subtree is `foo/**`. The published `put`/`get` +prefix encoding stays readable as subtrees, and subtree-only grants are still +written in it so older verifiers keep working. A wire message that carried +prefixes keeps them on the protocol versions that defined them; only new +versions carry patterns. The syntax follows Ant-style path patterns without `?`, classes, or braces. NATS subjects motivate segment wildcards and reserved wildcard bytes; Vault diff --git a/quest/m1/performance-comparisons.md b/quest/m1/performance-comparisons.md index 0169acbf11..e9fcaf8b3a 100644 --- a/quest/m1/performance-comparisons.md +++ b/quest/m1/performance-comparisons.md @@ -7,7 +7,7 @@ estimate and preserved evidence, so a small reported speedup can be evaluated. ## Plan -`rs/scripts/bench.sh` runs each relay workload once as base then current, +`bench/run.sh` runs each relay workload once as base then current, without repeated rounds or alternating execution order. `cleanup` deletes the run directory, including Criterion estimates, load/host JSONL, relay logs, and summaries. Preserve the existing default command @@ -24,7 +24,7 @@ while extending this harness rather than creating another benchmark runner. hardware/kernel, allocator, affinity, workload, and execution order. Preserve partial evidence on failure while still cleaning up owned processes/worktrees. - Distinguish throughput-window counters from cumulative latency/loss. Today - `rs/scripts/bench.sh::summarize_load` differences bytes over the last five seconds + the load summary in `bench/relay.sh` differences bytes over the last five seconds but reads final lifetime latency and group-loss counters. Label that explicitly; consume windowed data when the existing latency quest supplies it. Never subtract percentiles or call cumulative loss a steady-state sample. diff --git a/quest/m1/performance-profiles.md b/quest/m1/performance-profiles.md index 8fe119b4ee..eb52ef23b1 100644 --- a/quest/m1/performance-profiles.md +++ b/quest/m1/performance-profiles.md @@ -8,7 +8,7 @@ cost. Profiling is opt-in and has no production overhead when disabled. ## Plan -`rs/scripts/bench.sh` already owns the builds, relay PID, workload, and host +`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. diff --git a/quest/m1/play-harness.md b/quest/m1/play-harness.md deleted file mode 100644 index 2efdd2c7bb..0000000000 --- a/quest/m1/play-harness.md +++ /dev/null @@ -1,25 +0,0 @@ -# [M] moq play's task logic runs in CI without a device - -## Goal - -`moq play`'s media tasks (`rs/moq-cli/src/play/media.rs`: tune-in, rendition -switches, drains, and the playout queue) run in CI tests on every PR, without -a speaker, window, or display. Today the module sits behind the `play` -feature, which CI only compiles in the nightly clippy run, so its regressions -(#3946's tune-in burst, #3966's rendition-switch gap) are measured by hand. - -## Plan - -- Separate the task logic from the device: the tasks talk to a sink and a - wake handle the test replaces with a fake that records what was played and - when, on a paused tokio clock. Keep the seam private to `moq-cli`; no - test-only hooks in production paths beyond it. -- Run `cargo test -p moq-cli --features play` in the per-PR Test job, headless. -- Land the rendition-switch gap test on it, failing without #3966's fix. - The tune-in burst test moves onto the harness with - [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md), which - owns that fix. - -## Required - -- The rendition-switch gap fix (#3966) has merged diff --git a/quest/m1/play-tunein-backpressure.md b/quest/m1/play-tunein-backpressure.md index d2cc800900..b19a7bc2fd 100644 --- a/quest/m1/play-tunein-backpressure.md +++ b/quest/m1/play-tunein-backpressure.md @@ -29,15 +29,15 @@ duplicating the subscription and codec rules. Bound the encoded buffer by media age and account for its bytes; `--delay` allows 10s, which as raw 1080p frames would be ~900 MB. -The regression lives on #3946's branch (`quest/main/play-tunein-backpressure`, -`play::media::tests`): 61 frames at 30fps, a 2s delay, and no window drain park -the decoder at frame 31, leaving the newest frame due 990ms late. Port it onto +The regression lives in #3946 (`refs/pull/3946/head`, `play::media::tests`): +61 frames at 30fps, a 2s delay, and no window drain park the decoder at frame 31, leaving the newest frame due 990ms late. Port it onto the harness, and also cover video-only and speaker-owned anchors, delayed drains, reordering, discontinuity, and the decoder's tail flush. -## Required - -- [Play harness](/quest/m1/play-harness.md) - the regression test runs on it +The harness (`play::fake::Recorder`, driving `Media` on a paused tokio clock) +records the speaker and the window's wakes, but nothing presents video yet: a +test drains the shared queue itself, so add a fake presenter that pops frames +as `Presentation::due` allows, mirroring `window.rs`. ## Related diff --git a/quest/m1/qos/stats/schema.md b/quest/m1/qos/stats/schema.md index 8e4d21f181..2a60d265ec 100644 --- a/quest/m1/qos/stats/schema.md +++ b/quest/m1/qos/stats/schema.md @@ -49,10 +49,6 @@ what a subscriber received and played, per audio and video. The relay is connection carries. `Merge` sums counters, leaves gauges out of the sum, and keeps the newest liveness pair. -- The FlatBuffers flavor ([binary stats](/quest/m0/stats-binary/README.md)) - leaves an optional nested table for `E`. Either give `Ext` a FlatBuffers - encoding, or have a non-`()` producer refuse `.fb.z` requests and document - that. Whichever you choose, the contract must be explicit. - Naming: `.stats` as a broadcast suffix, documented in the `moq-stats` crate docs and `doc/lib/rs/index.md` beside the relay's prefix convention, with `moq_stats::is_stats(path)` so a dashboard filters telemetry from diff --git a/quest/m1/quic/reliable-reset.md b/quest/m1/quic/reliable-reset.md index aebca66342..10746d214f 100644 --- a/quest/m1/quic/reliable-reset.md +++ b/quest/m1/quic/reliable-reset.md @@ -56,9 +56,9 @@ the native interop matrix. Then use it in MoQ. A publisher that resets a group stream uses a Reliable Size covering the group header, so the subscriber can always attribute the reset to its group. Once reliable reset is negotiated, subscribers drop the -grace they wait for missing groups below a track's declared end (see the track -tail quests). Browsers keep the grace until WebTransport exposes -reliable reset. +grace they wait for missing groups below a track's declared end +(`rs/moq-net/src/tail.rs`, `js/net/src/tail.ts`). Browsers keep the grace until +WebTransport exposes reliable reset. Track the unversioned draft during implementation. The planning baseline is draft 10, with transport parameter `0x1d` and frame type `0x24`; do not freeze @@ -66,8 +66,8 @@ provisional codepoints if the document changes before release. ## Related -- [JS track tail](/quest/m1/js-track-tail.md) and [Rust track tail](/quest/m1/rust-track-tail.md) - - wait a grace for a group whose reset lost its header, until this lands +- moq-net (`rs/moq-net/src/tail.rs`) and `@moq/net` (`js/net/src/tail.ts`) wait + a grace for a group whose reset lost its header until this lands - [qmux on the QUIC stream state machine](/quest/m1/quic/qmux.md) - consumes the same reset state without a parallel implementation - The removed quiche backend was the one stack that had this, so it is the diff --git a/quest/m1/relay-peer-set.md b/quest/m1/relay-peer-set.md index 7ca9dcddbf..14a2e46b24 100644 --- a/quest/m1/relay-peer-set.md +++ b/quest/m1/relay-peer-set.md @@ -26,10 +26,6 @@ hop-id bit prefix (`agent/moq_agent/matching.py`). Public API: additive on moq-stats and moq-auth. Wire: the stats broadcast gains a peer-set frame; the JWT claims may gain a field. -## Required - -- [Binary stats](/quest/m0/stats-binary/README.md) - settles the stats track layout the frame joins - ## Related - [Route cost](/quest/m1/route-cost.md) - the other route fact JS lacks diff --git a/quest/m1/rust-track-tail.md b/quest/m1/rust-track-tail.md deleted file mode 100644 index 5af1ff663d..0000000000 --- a/quest/m1/rust-track-tail.md +++ /dev/null @@ -1,58 +0,0 @@ -# [M] Rust track tail - -## Goal - -A moq-net subscriber, lite and IETF, accepts every group below a track's -declared end until each is accounted for, even when its stream arrives after -the subscription's end, then ends cleanly. The IETF publisher reports how many -data streams it opened. The rule and the grace match the JS subscriber, so a -reader cannot tell which side it is talking to. - -## Plan - -Rust already records SUBSCRIBE_END with `finish_at`, which is not terminal, -and its publishers drain their group tasks before they end a subscription. The -remaining gap is the subscriber's bookkeeping: - -- Lite: once the subscribe stream FINs, `remove_subscribe` drops the entry, - so a group stream whose header decodes afterwards fails with - `Error::Cancel` in `lite/subscriber.rs`. Keep the entry, with its boundary, - until every group below the boundary is accounted for or the grace expires. -- IETF: retiring the alias on PublishDone makes a late stream fail with - `Error::Cancel` (`ietf/subscriber.rs`, `Alias::Retired`). Retire it only - once the streams are accounted for or the grace expires. -- Grace: a group reset before its header arrived can never be accounted for, - so give up after the same grace as the JS quest and end cleanly, skipping - the missing group as stale: the effective `max_age` as a wall-clock stopgap - on moq-lite (with a fallback when none is set), and a bounded wall-clock - wait on IETF. The reliable-reset quest removes both. -- Only stream-delivered groups are waited for; datagrams are never. -- IETF publisher: send the real number of data streams opened in PublishDone - instead of `stream_count: 0`. On receipt, treat the count as a hint: stop - waiting once that many are accounted for, but accept a late stream below the - boundary within the grace, so a published peer's 0 keeps working. -- IETF publisher on drafts 14-22: write the END_OF_TRACK object at the - boundary, which moq-net does not send today, and assert the - boundary end to end. - -Confirm Stream Count's meaning for the implemented IETF drafts first, and -where each draft carries the track's end (draft-07's SUBSCRIBE_DONE Final -Group and Object, or the END_OF_TRACK object on drafts 14-22), as the JS quest -does; both must agree. A PublishDone with an error status aborts the track -rather than ending it cleanly. - -Reproduce each case before fixing it: a group header decoded after the -subscribe stream's FIN, and a late stream after PublishDone, over the mock -session in `rs/moq-net/tests/support`. Add a Rust-JS interop case to -`just test smoke --all` for a publisher that ends a track with a group still -in flight. - -## Required - -- [lite-07 stream count](/quest/m1/lite-stream-count.md) - the moq-lite accounting this builds on, instead of SUBSCRIBE_DROP - -## Related - -- [JS track tail](/quest/m1/js-track-tail.md) - the same rule in `@moq/net` -- [Session death error](/quest/m1/session-death-error.md) - tracks ending wrong when the session dies -- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - removes the grace diff --git a/quest/m1/session-death-error.md b/quest/m1/session-death-error.md index 11df708a9a..b4112d5c24 100644 --- a/quest/m1/session-death-error.md +++ b/quest/m1/session-death-error.md @@ -53,8 +53,3 @@ controls: a finished track still ends clean while its session lives. ## Closes - [#4061](https://github.com/moq-dev/moq/issues/4061) - close this issue when the quest finishes - -## Related - -- [JS track tail](/quest/m1/js-track-tail.md) - the clean-end half: a track that did end is delivered whole -- [Rust track tail](/quest/m1/rust-track-tail.md) - the same in moq-net diff --git a/quest/m1/test-flakes.md b/quest/m1/test-flakes.md new file mode 100644 index 0000000000..909d6cbc28 --- /dev/null +++ b/quest/m1/test-flakes.md @@ -0,0 +1,22 @@ +# [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/tokio-reconnect-ports.md b/quest/m1/tokio-reconnect-ports.md deleted file mode 100644 index df925e6a82..0000000000 --- a/quest/m1/tokio-reconnect-ports.md +++ /dev/null @@ -1,25 +0,0 @@ -# [S] moq-tokio reconnect and worker tests bind their own ports - -## Goal - -The moq-tokio integration tests stop picking a free port, releasing it, and -binding it again, a race another process can win. `reconnect.rs`'s -`spawn_server` loses its retry loop, and the worker tests that do not need a -known port bind `:0`. - -## Plan - -- Add `Server::tcp_local_addr()` and `Listener::tcp_local_addr()`, mirroring - `websocket_local_addr()`, reporting the bound address of the plain TCP - (qmux) listener. Today `StreamListeners` keeps only the configured bind and - moves the bound listener into its accept task. `spawn_server` binds `:0` and - reads the address back. This is an additive public API. -- In `worker.rs`, the tests that only need some port bind `:0` through the - group and use `Group::local_addr()`. The tests that rebind the same port - after a drop, or probe it while the group holds it, keep a known port, which - is the behavior under test. -- No retries or sleeps. - -## Related - -- [Test ports](https://github.com/moq-dev/moq/pull/4084) - the same fix for `websocket_forbidden_does_not_end_a_quic_connect` diff --git a/quest/m1/track-tail-interop.md b/quest/m1/track-tail-interop.md new file mode 100644 index 0000000000..0078cdae17 --- /dev/null +++ b/quest/m1/track-tail-interop.md @@ -0,0 +1,34 @@ +# [S] Track tail interop + +## Goal + +`just test interop` covers a publisher that ends a track while its last group +is still in flight: a Rust publisher's track is read to its declared end by the +JS subscriber, and a JS publisher's by the Rust subscriber, through the relay. +Each reader gets every group below the end and then a clean end, never an +error or a stall. + +## Plan + +Both moq-net and `@moq/net` wait for a track's tail once the publisher ends a +subscription, and each is covered by its own in-process tests. Nothing checks +that the two agree across a relay on real QUIC. + +What stood in the way when the Rust half landed: + +- `moq import` exits the moment stdin ends, closing its session with the tail + still in flight, so a finite CLI publisher cannot end a track cleanly. The + fix is a publisher that waits for its subscriptions to drain before it + closes; `moq export`'s linger ([Export linger](/quest/m1/export-linger.md)) is + the reader-side cousin. +- The native JS subscriber (`test/interop/clients/js-native`) returns on the + first frame. It needs a mode that reads a track to its end and reports how it + ended and which groups it saw. + +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. + +## Related + +- [Session death error](/quest/m1/session-death-error.md) - the other way a + track ends wrong diff --git a/quest/m2/README.md b/quest/m2/README.md index c74ce97c32..d80049439f 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -18,6 +18,8 @@ upstream release waits in [m4](/quest/m4/README.md). - [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 - [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 @@ -27,15 +29,18 @@ 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 - [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 +- [NVENC held frames](/quest/m2/nvenc-held-frames.md) - moq-nvenc refuses or drives configurations whose frames the driver holds back - [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 @@ -54,13 +59,13 @@ upstream release waits in [m4](/quest/m4/README.md). - [QUIC GCC](/quest/m2/quic-gcc.md) - a measured verdict on delay-based congestion control for media egress, shipping as `RealTime` - [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 - [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 - [Keep-alive by deadline](/quest/m2/quic-keep-alive.md) - a PING only when the idle deadline nears, no fixed timer - [noq socket close](/quest/m2/noq-socket-close.md) - noq releases an endpoint's socket on close, so moq-tokio drops its wrapper -- [Bounded announce prefix table](/quest/m2/announce-prefix-table.md) - compress repeated path tuples on each ordered lite-08 announce stream, with bounded state and measured QUIC-byte savings - [Drop the hidden cluster exemption](/quest/m2/hidden-exemption.md) - relays stop forcing hidden broadcasts on cluster peers once every peer opts in on the wire - [Routing cost domains](/quest/m2/routing-cost-domains.md) - design operator boundaries and policy without adding incomparable costs - [Kernel pacing](/quest/m2/quic-kernel-pacing.md) - whether SO_TXTIME pacing beats a userspace pacer the io_uring driver ignores today @@ -75,11 +80,14 @@ upstream release waits in [m4](/quest/m4/README.md). - [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 - [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 index 3008bf0702..4f7a7371bf 100644 --- a/quest/m2/aac-encode-refusal.md +++ b/quest/m2/aac-encode-refusal.md @@ -4,17 +4,17 @@ 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` and `@moq/hang`'s -`audioSpecificConfig`. This mirrors the parse side, which since #4093 refuses -reserved values instead of guessing stereo. +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 -Both functions become fallible, a published API break in each language, so -this targets `dev`. Counts with a PCE-free configuration map as today. For -the others, either write channelConfiguration 0 with a PCE derived from the -layout, or refuse. Pick one at PR time and apply it in both languages. Test -every count from 1 to 8 and one beyond. +`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 diff --git a/quest/m2/announce-prefix-table.md b/quest/m2/announce-prefix-table.md deleted file mode 100644 index 564bbae493..0000000000 --- a/quest/m2/announce-prefix-table.md +++ /dev/null @@ -1,47 +0,0 @@ -# [L] Bounded announce prefix table - -## Goal - -MoQ-lite announcement starts can refer to repeated path-segment prefixes, -including a customer PID, using a bounded table scoped to one ordered Announce -Stream. Repeated-name traffic uses fewer actual network bytes without changing -the reconstructed path, route behavior, or the raw path length reported for -usage. The new encoding uses lite-08, while lite-07 and older versions remain -compatible. - -## Plan - -Extend the MoQ-lite draft and Rust encoder/decoder together. After the -ANNOUNCE_REQUEST and ANNOUNCE_OK exchange, each ordered Announce Stream owns an -initially empty prefix table. Define explicit insert/reference/literal forms -for `ANNOUNCE_START` path suffixes with whole-segment matching. Bound entries -and total bytes, specify deterministic eviction and stream reset, and allow a -literal when the table would not save bytes. Both peers process updates in -stream order, so an announcement never waits on a different stream's state. -Reject invalid references and lengths as protocol violations. Keep -`ANNOUNCE_END` and `ANNOUNCE_UPDATE` on their lite-06 IDs rather than re-sending -the path. - -Introduce `moq-lite-08` after lite-07. Keep lite-07 framing intact; do not -silently reinterpret an already negotiated stream. Prove mixed-version peers negotiate a common older version and that a new -stream after reconnect starts with an empty table. Exercise -literal fallback, repeated PID, nested tuple prefixes, table-full eviction, -malformed reference, duplicate route, and interleaved starts/ends in codec and -end-to-end relay tests. Update the MoQ-lite draft and any changed wire examples -in the same PR. - -Benchmark encoded control-message bytes and actual QUIC bytes with identical -announcement events and mesh topology, using a path sample shaped like the -health project: many unique timestamp suffixes under repeated -`/private/channel_.../stream-health-*` prefixes. Report the table hit -rate, network byte delta, CPU time, memory bound, and start/end counts. Compare -against lite-06 ID-based END alone so the table's incremental gain is clear. -Do not require customer clients to use the new version; the first adopter is -the internal moq.pro mesh after lite-06 has rolled out. - -## Related - -- [Relay memory](/quest/m1/relay-memory.md) - route state footprint, which - path encoding does not remove -- [PoP skipping](/quest/m1/pop-skipping/README.md) - the lite-06 rollout - work that must complete before moq.pro activates this encoding diff --git a/quest/m2/archive-backward-timestamps.md b/quest/m2/archive-backward-timestamps.md new file mode 100644 index 0000000000..c5175e4c41 --- /dev/null +++ b/quest/m2/archive-backward-timestamps.md @@ -0,0 +1,16 @@ +# [XS] Archive refuses backward timestamps + +## Goal + +A resumed recording refuses a track whose timestamps go backward past the +recovered timeline, failing loud like the group-ID check, instead of writing +overlapping media time. The caller starts a new prefix. + +## Plan + +Check the first group's timestamp against the recovered track's last recorded +timestamp at enrollment, and test both a backward and a forward restart. + +## Required + +- [Archive](/quest/m1/archive/README.md) - the recovery this hardens ships with the line diff --git a/quest/m2/archive-recovery-listing.md b/quest/m2/archive-recovery-listing.md new file mode 100644 index 0000000000..1c62bb5dc8 --- /dev/null +++ b/quest/m2/archive-recovery-listing.md @@ -0,0 +1,19 @@ +# [M] Archive recovery listing + +## Goal + +A DVR writer resuming a recording lists storage in proportion to what changed +since its last checkpoint, not every stored `groups/` object, while still never +deleting a referenced object. + +## Plan + +- Recovery today reconciles a complete `groups/` listing of every recorded + track before accepting input. Compare bounding it with an ordered + `list_with_offset` from the oldest retained range, with orphans below it left + to a background sweep, against a checkpointed listing marker. +- Benchmark recovery time and requests over archive size before and after. + +## Required + +- [Archive](/quest/m1/archive/README.md) - the recovery this bounds ships with the line diff --git a/quest/m2/audio-capture-time.md b/quest/m2/audio-capture-time.md new file mode 100644 index 0000000000..b39ee0c5d7 --- /dev/null +++ b/quest/m2/audio-capture-time.md @@ -0,0 +1,20 @@ +# [S] Native audio stamps acquisition, not arrival + +## Goal + +Native audio capture stamps a buffer at the instant its first sample was +captured, like native video stamps a frame's acquisition. Today the driver +stamps the broadcast clock when it reads the buffer, so audio lands at least +one device buffer (plus callback and queue latency) later than video captured +at the same instant. + +## Plan + +cpal reports `InputCallbackInfo::timestamp().capture` per callback; carry it +through `capture::Samples` and map it onto the broadcast clock once per open, +the way video maps its private capture timeline. Fall back to arrival where a +host reports nothing usable, and keep the reset-on-gap behavior. Measure the +skew before and after on at least one real device, and extend the moq-audio +clock fixtures so a synthetic buffer's capture instant is what publishes. + +Public API: none. Wire: none. diff --git a/quest/m2/auth-expired-error.md b/quest/m2/auth-expired-error.md new file mode 100644 index 0000000000..013ab441fe --- /dev/null +++ b/quest/m2/auth-expired-error.md @@ -0,0 +1,19 @@ +# [S] Expired token error + +## Goal + +A session whose token expired reports `moq_net::Error::Expired`, not +`Unauthorized`, over moq-lite and moq-transport, mirrored in `@moq/net` and +the bindings. A client can then refresh its token instead of treating the +refusal as final. + +## Plan + +- Add the variant to the `#[non_exhaustive]` error, so the change is additive + on `main`. Map it from lite's `AUTH_ERROR { Expired }` and moq-transport's + `EXPIRED_AUTH_TOKEN`, and back again when refusing. +- Carry it through moq-ffi's error mapping and each wrapper. + +## Required + +- [In-band auth](/quest/m1/auth/README.md) - the AUTH streams that carry these codes diff --git a/quest/m2/capture-clock-source.md b/quest/m2/capture-clock-source.md new file mode 100644 index 0000000000..ddd4d10888 --- /dev/null +++ b/quest/m2/capture-clock-source.md @@ -0,0 +1,20 @@ +# [S] Capture publishers read the catalog's clock + +## Goal + +`moq_video::encode::publish_capture` and `moq_audio::encode::Publication` stamp +on the clock their catalog advertises, with no separate clock to pass. Today +each takes its own `moq_mux::Clock`, and `PublicationOptions::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 `PublicationOptions`, 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/capture-ergonomics.md b/quest/m2/capture-ergonomics.md index c5bd12fd0f..0584416ba7 100644 --- a/quest/m2/capture-ergonomics.md +++ b/quest/m2/capture-ergonomics.md @@ -25,7 +25,3 @@ validation already has its own quest. the combination the `System` source makes newly reachable. Preserve exclusive AEC microphone ownership and define clock alignment before sharing processed microphone input. - -## Related - -- [Capture format validation](/quest/m1/audio-capture-format.md) - independent validation work already scoped diff --git a/quest/m2/hidden-exemption.md b/quest/m2/hidden-exemption.md index f39212684e..96ecf2afea 100644 --- a/quest/m2/hidden-exemption.md +++ b/quest/m2/hidden-exemption.md @@ -17,4 +17,4 @@ for peers that predate it. ## Required -- Every deployed relay in the moq.pro mesh speaks moq-lite-07 or MoQ Hidden. +- Every deployed relay in the moq.pro mesh speaks a finalized moq-lite-07 or MoQ Hidden; `moq-lite-07-wip` is opt-in only. diff --git a/quest/m2/js-discontinuity.md b/quest/m2/js-discontinuity.md new file mode 100644 index 0000000000..36e41aada4 --- /dev/null +++ b/quest/m2/js-discontinuity.md @@ -0,0 +1,29 @@ +# [S] JS names the break discontinuity() + +## 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. + +## 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. + +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. + +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. diff --git a/quest/m2/nvenc-held-frames.md b/quest/m2/nvenc-held-frames.md new file mode 100644 index 0000000000..1d84a329b5 --- /dev/null +++ b/quest/m2/nvenc-held-frames.md @@ -0,0 +1,24 @@ +# [S] moq-nvenc refuses or drives held frames + +## Goal + +A session whose configuration makes NVENC hold frames back (lookahead or +B-frames) either fails at `start_session` or encodes correctly. Today every +such submission fails at `Submission::finish` with `InvalidParam`. + +## Plan + +NVENC answers a held frame with `NV_ENC_ERR_NEED_MORE_INPUT` and forbids +locking its output until a later submission returns success. The facade +returns a `Submission` for it anyway, and `finish` locks at once. P7 with +high-quality tuning enables lookahead (depth 28 on an RTX 3070 Ti), so it +fails on every frame. `moq-video` avoids this with low-latency tuning and no +B-frames, so no caller hits it today. + +Recommended: refuse at `start_session` when the config enables lookahead or +sets `frameIntervalP > 1`, and when no config is given, check the preset +config for those settings. Driving held frames means returning outputs in +submission order across calls, which no consumer needs yet. The hardware +test `failed_submission_releases_the_session` in +`rs/moq-nvenc/src/safe/session.rs` relies on lookahead to force the failure; +move it to another forcing config when this lands. diff --git a/quest/m2/obs-wave-layout.md b/quest/m2/obs-wave-layout.md new file mode 100644 index 0000000000..b62afa7030 --- /dev/null +++ b/quest/m2/obs-wave-layout.md @@ -0,0 +1,17 @@ +# [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/stats-delta.md b/quest/m2/stats-delta.md index 19bd074bb2..b60e1b9afa 100644 --- a/quest/m2/stats-delta.md +++ b/quest/m2/stats-delta.md @@ -100,7 +100,7 @@ has landed by then. Share fixtures between Rust and JS so the two stay wire identical, and keep the benchmark in-tree, wired into CI at least nightly. **Docs and spec.** No IETF draft covers stats today. Write the format into -the stats format page ([stats-binary docs](/quest/m0/stats-binary/docs.md)) +the stats format page ([doc/concept/stats.md](/doc/concept/stats.md)) and the stats section of `doc/bin/relay/config.md`, plus the moq-stats crate docs. Whether stats needs its own `draft-lcurley-moq-stats.md` is the maintainer's call; ask before writing one. @@ -110,6 +110,6 @@ impact: new on-demand tracks; existing tracks unchanged. ## Related -- [Stats format page](/quest/m0/stats-binary/docs.md) - where the new flavor is documented +- [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 diff --git a/quest/m2/teleop/correlation.md b/quest/m2/teleop/correlation.md index 779d6f6e07..bb8e60ac91 100644 --- a/quest/m2/teleop/correlation.md +++ b/quest/m2/teleop/correlation.md @@ -30,4 +30,3 @@ the same property that makes an MCAP recording valuable. ## Required - [Robot teleoperation primitive](/quest/m2/teleop/robot.md) -- [CLI import clock](/quest/m1/cli-import-clock.md) - publishers populate the fixed broadcast mapping used to join tracks diff --git a/quest/m2/teleop/robot.md b/quest/m2/teleop/robot.md index 6cd731e4c5..42614ceb11 100644 --- a/quest/m2/teleop/robot.md +++ b/quest/m2/teleop/robot.md @@ -64,7 +64,7 @@ The framing is where the guarantee lives, not the subscription flags: - 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 data producers ([data sections](/quest/m1/data-sections.md)). + through the `moq-mux` data producers. 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 @@ -81,10 +81,6 @@ Port `moq-boy` onto the crate in the same change, as the no-arbitration case. It is the only existing consumer, and if the abstraction cannot express crowd control then it is the wrong abstraction. -## Required - -- [Data sections](/quest/m1/data-sections.md) - publishes the telemetry section's data tracks - ## Related - [arbitration](/quest/m2/teleop/arbitration.md) - which controller is obeyed diff --git a/quest/m2/whep-abr.md b/quest/m2/whep-abr.md new file mode 100644 index 0000000000..ed9896b80a --- /dev/null +++ b/quest/m2/whep-abr.md @@ -0,0 +1,21 @@ +# [L] WHEP adaptive rendition switching + +## Goal + +A WHEP viewer of a multi-rendition broadcast is switched between video +renditions from its own congestion feedback, the way `@moq/watch` switches +over MoQ, so a weak link gets SD instead of stalling on HD. + +## Plan + +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 diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 50a8bc4ca9..c547539b83 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.5](https://github.com/moq-dev/moq/compare/hang-v0.21.4...hang-v0.21.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json + +## [0.21.4](https://github.com/moq-dev/moq/compare/hang-v0.21.3...hang-v0.21.4) - 2026-09-25 + +### Added + +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.21.3](https://github.com/moq-dev/moq/compare/hang-v0.21.2...hang-v0.21.3) - 2026-09-25 ### Other diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 7d413b6b87..2113563ee7 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.3" +version = "0.21.5" edition = "2024" rust-version.workspace = true diff --git a/rs/hang/src/catalog/binary.rs b/rs/hang/src/catalog/binary.rs index 7a89406785..7c4ec811eb 100644 --- a/rs/hang/src/catalog/binary.rs +++ b/rs/hang/src/catalog/binary.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, btree_map}; use serde::{Deserialize, Serialize}; +use crate::catalog::millis::MillisCeil; use crate::catalog::{Compression, Mode}; /// The binary tracks a broadcast publishes, keyed by track name. @@ -86,6 +87,16 @@ pub struct BinaryConfig { #[serde(default)] pub mime: Option, + /// The maximum bitrate of the track in bits per second, if known. + #[serde(default)] + pub bitrate: Option, + + /// The maximum delay between a payload being ready and the publisher flushing it, with the same + /// meaning and whole-millisecond encoding as [`VideoConfig::jitter`](crate::catalog::VideoConfig::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub jitter: 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 @@ -104,7 +115,16 @@ impl BinaryConfig { mode, compression: None, mime: None, + bitrate: None, + jitter: None, extra: Default::default(), } } } + +/// The config itself, so a data producer takes it wherever it takes an entry embedding one. +impl AsMut for BinaryConfig { + fn as_mut(&mut self) -> &mut Self { + self + } +} diff --git a/rs/hang/src/catalog/json.rs b/rs/hang/src/catalog/json.rs index cdc85a0397..9fa0d355c2 100644 --- a/rs/hang/src/catalog/json.rs +++ b/rs/hang/src/catalog/json.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, btree_map}; use serde::{Deserialize, Serialize}; +use crate::catalog::millis::MillisCeil; use crate::catalog::{Compression, Mode}; /// The JSON tracks a broadcast publishes, keyed by track name. @@ -84,6 +85,16 @@ pub struct JsonConfig { #[serde(default)] pub schema: Option, + /// The maximum bitrate of the track in bits per second, if known. + #[serde(default)] + pub bitrate: Option, + + /// The maximum delay between a payload being ready and the publisher flushing it, with the same + /// meaning and whole-millisecond encoding as [`VideoConfig::jitter`](crate::catalog::VideoConfig::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub jitter: 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 @@ -102,7 +113,16 @@ impl JsonConfig { mode, compression: None, schema: None, + bitrate: None, + jitter: None, extra: Default::default(), } } } + +/// The config itself, so a data producer takes it wherever it takes an entry embedding one. +impl AsMut for JsonConfig { + fn as_mut(&mut self) -> &mut Self { + self + } +} diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index baad3caae8..21c1e11e85 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -780,6 +780,68 @@ mod test { assert_eq!(output, encoded, "encode mismatch"); } + /// Data tracks carry the same optional `bitrate` and whole-millisecond `jitter` 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 mut gps = JsonConfig::new(Mode::Stream); + gps.bitrate = Some(8_000); + gps.jitter = Some(std::time::Duration::from_millis(100)); + + let mut frames = BinaryConfig::new(Mode::Snapshot); + frames.bitrate = Some(64_000); + frames.jitter = Some(std::time::Duration::from_micros(33_334)); + + let mut catalog = Catalog::<()>::default(); + catalog.json.insert("gps", gps).unwrap(); + catalog.binary.insert("frames", frames).unwrap(); + + assert_eq!( + catalog.to_json().unwrap(), + encoded, + "jitter rounds up to whole milliseconds" + ); + + let decoded = Catalog::<()>::from_str(encoded).unwrap(); + assert_eq!( + decoded.binary.tracks["frames"].jitter, + Some(std::time::Duration::from_millis(34)) + ); + assert_eq!(decoded.json.tracks["gps"].bitrate, Some(8_000)); + } + + /// An application lists a data track in its own section by flattening a data config beside its + /// own fields, so the reading rules and the application's fields share one entry. + #[test] + fn a_data_config_flattens_into_an_application_entry() { + #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] + struct Mavlink { + #[serde(flatten)] + binary: BinaryConfig, + sysid: u8, + } + + #[derive(Serialize, Deserialize, PartialEq, Debug, Default, Clone)] + struct Ext { + #[serde(rename = "com.example.mavlink", default)] + mavlink: BTreeMap, + } + + let encoded = r#"{"video":{"renditions":{}},"audio":{"renditions":{}},"com.example.mavlink":{"telemetry":{"mode":"stream","compression":"deflate","sysid":1}}}"#; + + let catalog = Catalog::::from_str(encoded).unwrap(); + let entry = &catalog.ext.mavlink["telemetry"]; + assert_eq!(entry.sysid, 1); + assert_eq!(entry.binary.mode, Mode::Stream); + assert_eq!(entry.binary.compression, Some(Compression::Deflate)); + assert!( + entry.binary.extra.is_empty(), + "the application's own fields are not unknown data-track fields" + ); + assert_eq!(catalog.to_json().unwrap(), encoded); + } + /// A track using a future mode or compression must survive a reparse-and-republish intact, so a /// relay doesn't corrupt what it can't read. Its siblings stay readable. #[test] diff --git a/rs/justfile b/rs/justfile index 4469fd9330..e7fcbeb580 100644 --- a/rs/justfile +++ b/rs/justfile @@ -291,6 +291,15 @@ _wants-capture $PACKAGES: set -euo pipefail grep -qwE '(moq-video|moq-audio)' <<< "$(just rs _names "$PACKAGES")" +# True when the selection includes moq-cli, whose `play` media tasks are off the +# default feature set. Selection pulls in dependents, so a diff to anything the +# player is built from (moq-audio, moq-video, moq-mux) lands here too. +[private] +_wants-play $PACKAGES: + #!/usr/bin/env bash + set -euo pipefail + grep -qw 'moq-cli' <<< "$(just rs _names "$PACKAGES")" + # True when the selection includes moq-relay, whose io_uring listener is off the # default feature set. Selection pulls in dependents, so a diff to any crate the # relay is built from (moq-uring, moq-sock, moq-tokio) lands here too. @@ -495,9 +504,20 @@ check-changed $FILES $TEST="false": # moq-video's and moq-audio's device code is behind `capture`, which is off # by default, so the pass above never compiles it. Only nightly's - # `--all-features` did, and a break there lands on main. + # `--all-features` did, and a break there lands on main. With TEST=true the + # lint rides the test build, as in `check-test`. if [[ "$packages" == "ALL" ]] || just rs _wants-capture "$packages"; then - just rs capture + if [[ "$TEST" == true ]]; then + CARGO_BUILD_WARNINGS=deny just rs capture-test + else + just rs capture + fi + fi + + # moq-cli's media tasks are behind `play`, off by default, so the test pass + # above never runs them. Only `check` tests, so the lint-only CI job skips it. + if [[ "$TEST" == true ]] && { [[ "$packages" == "ALL" ]] || just rs _wants-play "$packages"; }; then + just rs play fi # moq-relay's io_uring listener is behind `io-uring`, off by default, so the @@ -628,6 +648,13 @@ macos *args: capture *args: cargo clippy --locked -p moq-video -p moq-audio --all-targets --features moq-video/capture,moq-audio/capture {{ args }} -- -D warnings +# The capture publishers' tests (the clock fixtures among them) drive synthetic +# devices, so they need no hardware and run with every PR that reaches either crate. + +# Test the capture feature, which the default test pass skips. +capture-test *args: + just rs test -p moq-video -p moq-audio --features moq-video/capture,moq-audio/capture {{ args }} + # Same idea as `windows`/`macos`, for Android: moq-video's MediaCodec encoder and # decoder, its `Surface::HardwareBuffer` variant, and the `frame::android` module # are all `#[cfg(target_os = "android")]`, so a host-target check compiles none of @@ -886,6 +913,23 @@ test-changed $FILES: *) echo "rs: testing $(just rs _names "$packages")"; just rs test --no-tests=pass $packages ;; esac + # Mirrors `check-changed`: the capture tests are off the default feature set. + if [[ "$packages" == "ALL" ]] || just rs _wants-capture "$packages"; then + just rs capture-test + fi + + if [[ "$packages" == "ALL" ]] || just rs _wants-play "$packages"; then + just rs play + fi + +# The media tasks run against a recorder rather than a device (see +# `rs/moq-cli/src/play/fake.rs`), so this needs no speaker or display, only the +# device crates' build dependencies, which the dev shell carries. + +# Test moq-cli's `play` feature, which default features leave out. +play *args: + just rs test -p moq-cli --features play {{ args }} + # Compile and run the `/// ```` examples, which nextest skips. doctest *args: cargo test --locked --doc {{ args }} diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index b0494602f6..8a8498ced7 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.5](https://github.com/moq-dev/moq/compare/libmoq-v0.6.4...libmoq-v0.6.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux, moq-tokio, moq-audio, moq-video, moq-json, hang + +## [0.6.4](https://github.com/moq-dev/moq/compare/libmoq-v0.6.3...libmoq-v0.6.4) - 2026-09-25 + +### Added + +- *(libmoq)* advertise JSON tracks in the catalog, add binary data tracks ([#4073](https://github.com/moq-dev/moq/pull/4073)) +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.6.3](https://github.com/moq-dev/moq/compare/libmoq-v0.6.2...libmoq-v0.6.3) - 2026-09-25 ### Added diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 110459dd6b..118272a410 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.3" +version = "0.6.5" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 42bfcecbe7..66e353a052 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -890,6 +890,9 @@ static VERSION_NAMES: std::sync::LazyLock> = /// first. Each name borrows a static string valid for the life of the process, so a /// caller building a menu can hold them indefinitely. /// +/// Work-in-progress versions are omitted, since they are not advertised unless pinned; +/// a dial still accepts them by name. +/// /// Returns the total count on success, or a negative code on failure. /// /// # Safety diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index dbc51f9fd9..a1cfbf4762 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.5](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.4...moq-archive-v0.0.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + +## [0.0.4](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.3...moq-archive-v0.0.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.0.3](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.2...moq-archive-v0.0.3) - 2026-09-25 ### Other diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 53500dd89c..538837409d 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.3" +version = "0.0.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index aec7067d3d..a2c05831c2 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.4](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.3...moq-audio-v0.1.4) - 2026-09-25 + +### Fixed + +- *(audio)* honor and validate Opus stream descriptions ([#4130](https://github.com/moq-dev/moq/pull/4130)) + +### Other + +- *(capture)* drive native capture through clock edge cases in CI ([#4125](https://github.com/moq-dev/moq/pull/4125)) + +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.2...moq-audio-v0.1.3) - 2026-09-25 + +### Added + +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.1...moq-audio-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 40cd259d15..67de1334a0 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.2" +version = "0.1.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/src/capture.rs b/rs/moq-audio/src/capture.rs index 2dc70d58eb..d332e8d381 100644 --- a/rs/moq-audio/src/capture.rs +++ b/rs/moq-audio/src/capture.rs @@ -63,9 +63,13 @@ impl Default for Source { /// denies microphone access. const FIRST_BUFFER_TIMEOUT: Duration = Duration::from_secs(5); -/// Audio capture configuration. All fields are hints; the backend picks the -/// closest supported mode and the [`encode::Producer`](crate::encode::Producer) -/// resamples to the codec rate anyway. +/// Audio capture configuration. +/// +/// `sample_rate` and `channels` are requirements, not hints: a device that +/// cannot capture the requested format fails with [`Error::Unsupported`] instead +/// of opening at another one. Leave them `None` to take the device's default; +/// the [`encode::Producer`](crate::encode::Producer) resamples and remixes to the +/// codec format either way. /// /// `#[non_exhaustive]`: construct via [`Config::default`] and set fields, so /// new options can be added without breaking callers. @@ -74,9 +78,9 @@ const FIRST_BUFFER_TIMEOUT: Duration = Duration::from_secs(5); pub struct Config { /// What to capture. pub source: Source, - /// Samples per second to ask the device for. `None` takes its default. + /// Samples per second the device must capture at. `None` takes its default. pub sample_rate: Option, - /// Channels to ask the device for. `None` takes its default. + /// Channels the device must capture. `None` takes its default. pub channels: Option, /// Cancel the echo of what a speaker is playing, controlled by /// [`Engine::canceller`](crate::playback::Engine::canceller). @@ -179,9 +183,24 @@ impl Failure { } fn cpal(error: cpal::Error) -> Self { - let retryable = retryable(error.kind()); - let error = capture_err(error); - if retryable { + Self::classify(error.kind(), capture_err(error)) + } + + /// A failure to build or start a stream, naming the device and format so a + /// backend refusal reads as the request it refused. + fn opening(error: cpal::Error, device: &str, layout: Layout) -> Self { + let Layout { sample_rate, channels } = layout; + let kind = error.kind(); + let message = format!("microphone {device} at {sample_rate} Hz with {channels} channels: {error}"); + let error = match kind { + cpal::ErrorKind::UnsupportedConfig | cpal::ErrorKind::InvalidInput => Error::Unsupported(message), + _ => Error::Capture(message), + }; + Self::classify(kind, error) + } + + fn classify(kind: cpal::ErrorKind, error: Error) -> Self { + if retryable(kind) { Self::Retry(error) } else { Self::Fatal(error) @@ -252,7 +271,7 @@ pub(crate) async fn format(config: &Config) -> Result { let (_, _, _, stream_config) = resolve(device.as_deref(), &config)?; Ok(Layout { sample_rate: stream_config.sample_rate, - channels: stream_config.channels as u32, + channels: u32::from(stream_config.channels), }) }) .await @@ -358,7 +377,9 @@ impl Microphone { let (device, current, sample_format, stream_config) = resolve(selector, config)?; let sample_rate = stream_config.sample_rate; - let channels = stream_config.channels as u32; + let channels = u32::from(stream_config.channels); + let layout = Layout { sample_rate, channels }; + let opening = |err| Failure::opening(err, &device.to_string(), layout); // Claim the adaptive state and tell it what it is listening to before the // first callback arrives, so the buffers it needs are allocated off the @@ -413,13 +434,13 @@ impl Microphone { } other => { return Err(Failure::fatal(Error::Unsupported(format!( - "unsupported input sample format {other:?}" + "microphone {device} captures {other}, which is not a supported sample format" )))); } } - .map_err(Failure::cpal)?; + .map_err(opening)?; - stream.play().map_err(Failure::cpal)?; + stream.play().map_err(opening)?; // Await the first buffer to surface a permission failure (or dead device) // as an error rather than a silent hang in the capture loop. @@ -444,7 +465,7 @@ impl Microphone { _stream: stream, reader, pending: Some(pending), - layout: Layout { sample_rate, channels }, + layout, device: current, }) } @@ -601,15 +622,81 @@ fn resolve( let current = describe(&device, &id, Some(&id) == default.as_ref()).map_err(Failure::cpal)?; let supported = device.default_input_config().map_err(Failure::cpal)?; - let sample_format = supported.sample_format(); - let mut stream_config = supported.config(); - if let Some(rate) = config.sample_rate { - stream_config.sample_rate = rate; - } - if let Some(channels) = config.channels { - stream_config.channels = channels as u16; - } - Ok((device, current, sample_format, stream_config)) + // Enumerate only for an override: the default needs no range check, and a + // host that cannot list its ranges can still open its default. + let supported = if config.sample_rate.is_none() && config.channels.is_none() { + supported + } else { + let ranges = device.supported_input_configs().map_err(Failure::cpal)?; + negotiate(&device.to_string(), supported, ranges, config).map_err(Failure::fatal)? + }; + Ok((device, current, supported.sample_format(), supported.config())) +} + +/// Pick the device format matching `config`'s overrides from the `ranges` the +/// device reports, refusing a format it cannot capture rather than substituting +/// the nearest one. +fn negotiate( + device: &str, + default: cpal::SupportedStreamConfig, + ranges: impl IntoIterator, + config: &Config, +) -> Result { + // Compare in u32 so an out-of-range channel count matches nothing instead of + // wrapping when narrowed to cpal's u16. + let sample_rate = config.sample_rate.unwrap_or(default.sample_rate()); + let channels = config.channels.unwrap_or(u32::from(default.channels())); + if sample_rate == default.sample_rate() && channels == u32::from(default.channels()) { + return Ok(default); + } + + let mut usable: Vec<_> = ranges + .into_iter() + .filter(|range| writable(range.sample_format())) + .collect(); + let preferred = |range: &cpal::SupportedStreamConfigRange| range.sample_format() == default.sample_format(); + let best = usable + .iter() + .filter(|range| u32::from(range.channels()) == channels && range.contains_rate(sample_rate)) + // Keep the default's sample format when it can, then take cpal's preference. + .max_by(|a, b| { + preferred(a) + .cmp(&preferred(b)) + .then_with(|| a.cmp_default_heuristics(b)) + }); + if let Some(best) = best { + return Ok(best.with_sample_rate(sample_rate)); + } + + usable.sort_by_key(|range| (range.channels(), range.min_sample_rate(), range.max_sample_rate())); + let supported: Vec<_> = usable + .iter() + .map(|range| { + format!( + "{} channels at {}-{} Hz ({})", + range.channels(), + range.min_sample_rate(), + range.max_sample_rate(), + range.sample_format() + ) + }) + .collect(); + let supported = if supported.is_empty() { + "no usable format".to_string() + } else { + supported.join(", ") + }; + Err(Error::Unsupported(format!( + "microphone {device} cannot capture {sample_rate} Hz with {channels} channels; it supports {supported}" + ))) +} + +/// Sample formats the capture callback converts to `f32`. +fn writable(format: cpal::SampleFormat) -> bool { + matches!( + format, + cpal::SampleFormat::F32 | cpal::SampleFormat::I16 | cpal::SampleFormat::U16 + ) } /// Build the listing entry for `device`, whose id the caller has already read. @@ -734,6 +821,117 @@ mod tests { assert!(!failure.is_retryable()); } + const MIC: &str = "Test Mic"; + + fn default_config() -> cpal::SupportedStreamConfig { + cpal::SupportedStreamConfig::new(2, 48_000, cpal::SupportedBufferSize::Unknown, cpal::SampleFormat::F32) + } + + fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange { + cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Unknown, format) + } + + /// A device offering mono and stereo from 8 to 48 kHz as I16, and stereo at + /// 44.1 to 48 kHz as F32. + fn ranges() -> Vec { + vec![ + range(1, 8_000, 48_000, cpal::SampleFormat::I16), + range(2, 8_000, 48_000, cpal::SampleFormat::I16), + range(2, 44_100, 48_000, cpal::SampleFormat::F32), + // Unwritable formats are never picked, even when they match. + range(1, 96_000, 96_000, cpal::SampleFormat::I32), + ] + } + + fn request(sample_rate: Option, channels: Option) -> Config { + Config { + sample_rate, + channels, + ..Default::default() + } + } + + fn unsupported(result: Result) -> String { + match result { + Err(Error::Unsupported(message)) => message, + other => panic!("expected an unsupported format, got {other:?}"), + } + } + + #[test] + fn requesting_the_default_keeps_it() { + let config = negotiate(MIC, default_config(), [], &request(Some(48_000), Some(2))).unwrap(); + assert_eq!(config, default_config()); + } + + #[test] + fn an_override_keeps_the_default_sample_format_when_it_can() { + let config = negotiate(MIC, default_config(), ranges(), &request(Some(44_100), None)).unwrap(); + assert_eq!(config.sample_rate(), 44_100); + assert_eq!(config.channels(), 2); + assert_eq!(config.sample_format(), cpal::SampleFormat::F32); + } + + #[test] + fn an_override_takes_another_sample_format_from_the_device_ranges() { + let config = negotiate(MIC, default_config(), ranges(), &request(None, Some(1))).unwrap(); + assert_eq!(config.sample_rate(), 48_000); + assert_eq!(config.channels(), 1); + assert_eq!(config.sample_format(), cpal::SampleFormat::I16); + + let config = negotiate(MIC, default_config(), ranges(), &request(Some(16_000), None)).unwrap(); + assert_eq!(config.sample_rate(), 16_000); + assert_eq!(config.channels(), 2); + assert_eq!(config.sample_format(), cpal::SampleFormat::I16); + } + + #[test] + fn an_unsupported_sample_rate_is_refused_with_context() { + let message = unsupported(negotiate(MIC, default_config(), ranges(), &request(Some(96_000), None))); + assert!(message.contains(MIC), "{message}"); + assert!(message.contains("96000 Hz with 2 channels"), "{message}"); + assert!(message.contains("1 channels at 8000-48000 Hz (i16)"), "{message}"); + assert!(!message.contains("i32"), "{message}"); + } + + #[test] + fn a_channel_count_beyond_u16_does_not_wrap() { + // 65537 narrowed to u16 is 1, which the device supports. + let message = unsupported(negotiate(MIC, default_config(), ranges(), &request(None, Some(65_537)))); + assert!(message.contains("48000 Hz with 65537 channels"), "{message}"); + } + + #[test] + fn a_device_with_no_usable_ranges_is_refused() { + let ranges = [range(1, 8_000, 48_000, cpal::SampleFormat::I32)]; + let message = unsupported(negotiate(MIC, default_config(), ranges, &request(None, Some(1)))); + assert!(message.contains("no usable format"), "{message}"); + } + + #[test] + fn a_refused_open_names_the_device_and_format() { + let layout = Layout { + sample_rate: 44_100, + channels: 1, + }; + + let failure = Failure::opening(cpal::Error::new(cpal::ErrorKind::UnsupportedConfig), MIC, layout); + assert!(!failure.is_retryable()); + match failure.into_error() { + Error::Unsupported(message) => { + assert!(message.contains("Test Mic at 44100 Hz with 1 channels"), "{message}") + } + other => panic!("expected an unsupported format, got {other:?}"), + } + + let failure = Failure::opening(cpal::Error::new(cpal::ErrorKind::DeviceBusy), MIC, layout); + assert!(failure.is_retryable()); + match failure.into_error() { + Error::Capture(message) => assert!(message.contains("Test Mic at 44100 Hz with 1 channels"), "{message}"), + other => panic!("expected a capture failure, got {other:?}"), + } + } + #[test] fn opaque_backend_errors_are_not_retryable() { let failure = Failure::cpal(cpal::Error::new(cpal::ErrorKind::BackendError)); diff --git a/rs/moq-audio/src/capture/screencapture.rs b/rs/moq-audio/src/capture/screencapture.rs index 73e5eadc73..5fbb32ea04 100644 --- a/rs/moq-audio/src/capture/screencapture.rs +++ b/rs/moq-audio/src/capture/screencapture.rs @@ -140,7 +140,7 @@ impl SystemAudio { // backend that quietly picked a different layout would hand the encoder // wrong-shaped frames. Fail loudly instead. if pending.channels != channels { - return Err(Error::Capture(format!( + return Err(Error::Unsupported(format!( "system audio delivered {} channels, not the {channels} requested", pending.channels ))); diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index 0f7ee37fb6..52f448a532 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -576,6 +576,48 @@ mod tests { } } + /// An imported 44.1 kHz Opus stream decodes on the 48 kHz clock: the pre-skip + /// is trimmed once as padding before the first packet, and every later frame + /// is stamped where its samples fall. + #[tokio::test] + async fn opus_timestamps_follow_the_48k_clock() { + use crate::decode::decoder::tests::{opus_catalog, opus_packets}; + + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast + .create_track("audio", hang::container::track_info(hang::catalog::PRIORITY.audio)) + .unwrap(); + let subscriber = broadcast.consume(); + + let catalog = opus_catalog(moq_mux::codec::opus::Config::new(44_100, 1).with_pre_skip(312)); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), + ); + let mut consumer = Consumer::new(&subscriber, &catalog, "audio", Options::new()) + .await + .unwrap(); + assert_eq!(consumer.sample_rate(), 48_000); + + for (packet, payload) in opus_packets(3).into_iter().enumerate() { + producer + .write(moq_mux::container::Frame { + timestamp: Timestamp::from_micros(packet as u64 * 20_000).unwrap(), + duration: None, + payload, + keyframe: packet == 0, + }) + .unwrap(); + } + + // 312 samples at 48 kHz is 6.5 ms. + for (micros, frames) in [(0, 960 - 312), (13_500, 960), (33_500, 960)] { + let frame = consumer.read().await.unwrap().expect("decoded frame"); + assert_eq!(frame.timestamp.as_micros(), micros); + assert_eq!(frame.data.len() / size_of::(), frames); + } + } + /// A packet whose sample count isn't a multiple of the resampler's chunk leaves /// samples buffered, and the next output starts with those. Stamping that /// output with the packet that completed the chunk puts it up to a chunk late, diff --git a/rs/moq-audio/src/decode/decoder.rs b/rs/moq-audio/src/decode/decoder.rs index 5f85d66c1c..3d813c40c6 100644 --- a/rs/moq-audio/src/decode/decoder.rs +++ b/rs/moq-audio/src/decode/decoder.rs @@ -4,8 +4,8 @@ //! catalog codec and produces interleaved `f32` PCM. use unsafe_libopus::{ - OPUS_OK, OPUS_RESET_STATE, OpusDecoder, opus_decode_float, opus_decoder_create, opus_decoder_ctl_impl, - opus_decoder_destroy, varargs, + OPUS_OK, OPUS_RESET_STATE, OPUS_SET_GAIN_REQUEST, OpusDecoder, opus_decode_float, opus_decoder_create, + opus_decoder_ctl_impl, opus_decoder_destroy, varargs, }; #[cfg(feature = "aac")] @@ -89,9 +89,11 @@ struct Aac { impl Decoder { /// Build a decoder from a catalog [`AudioConfig`](hang::catalog::AudioConfig). /// - /// Parses the OpusHead `description` if present; falls back to the catalog's - /// declared sample rate / channel count. PCM uses those catalog fields - /// directly and requires an absent `description`. + /// Opus decodes at 48 kHz with the pre-skip and gain its OpusHead + /// `description` declares, refusing a malformed head or a channel mapping + /// other than mono/stereo; without a description it takes the catalog's + /// channel count and applies neither. PCM uses the catalog fields directly + /// and requires an absent `description`. pub fn new(catalog: &hang::catalog::AudioConfig, config: &Config) -> Result { let name = match &catalog.codec { hang::catalog::AudioCodec::Opus => "opus", @@ -119,39 +121,36 @@ impl Decoder { } fn new_opus(catalog: &hang::catalog::AudioConfig) -> Result { - let (sample_rate, channel_count, pre_skip) = if let Some(desc) = &catalog.description { - let mut buf = desc.as_ref(); - match moq_mux::codec::opus::Config::parse(&mut buf) { - Ok(head) => (head.sample_rate, head.channel_count, head.pre_skip), - Err(_) => (catalog.sample_rate, catalog.channel_count, 0), - } - } else { - (catalog.sample_rate, catalog.channel_count, 0) - }; - - opus::validate_rate(sample_rate)?; - let channels = opus::validate_channels(channel_count)?; + let head = opus::head(catalog)?; + let channels = opus::validate_channels(head.channel_count)?; let mut err = 0i32; // SAFETY: out-pointer is valid; inner is checked for null below. - let inner = unsafe { opus_decoder_create(sample_rate as i32, channels, &mut err) }; + let inner = unsafe { opus_decoder_create(opus::DECODE_RATE as i32, channels, &mut err) }; if err != OPUS_OK || inner.is_null() { return Err(opus::error(err, "opus_decoder_create")); } + // Owned before the gain ctl so a failure there still destroys the decoder. + let decoder = Opus { + inner, + pre_skip_remaining: head.pre_skip as usize, + max_frame_size: (opus::DECODE_RATE as usize * MAX_FRAME_MS) / 1000, + in_dtx: false, + }; - let max_frame_size = (sample_rate as usize * MAX_FRAME_MS) / 1000; - let pre_skip_remaining = (pre_skip as usize * sample_rate as usize) / 48_000; + // Same Q7.8 dB units as OpusHead, and it survives OPUS_RESET_STATE. + // SAFETY: `inner` owns a live decoder and OPUS_SET_GAIN takes one i32. + let rc = + unsafe { opus_decoder_ctl_impl(decoder.inner, OPUS_SET_GAIN_REQUEST, varargs![head.output_gain as i32]) }; + if rc != OPUS_OK { + return Err(opus::error(rc, "OPUS_SET_GAIN")); + } Ok(Self { - backend: Backend::Opus(Opus { - inner, - pre_skip_remaining, - max_frame_size, - in_dtx: false, - }), - sample_rate, - layout: Layout::from_channels(channel_count)?, - delay: pre_skip_remaining, + delay: decoder.pre_skip_remaining, + backend: Backend::Opus(decoder), + sample_rate: opus::DECODE_RATE, + layout: Layout::from_channels(head.channel_count)?, }) } @@ -371,7 +370,7 @@ impl Drop for Opus { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; /// Three consecutive AAC-LC frames of a 440 Hz full-scale sine, mono at @@ -462,6 +461,145 @@ mod tests { assert!(matches!(decoder.decode(&[0xFF; 3]), Err(Error::Decode(_)))); } + /// Real Opus: 20 ms packets of a continuous 440 Hz sine, mono, from libopus + /// with its 312-sample lookahead. + pub(crate) fn opus_packets(count: usize) -> Vec { + let mut encoder = crate::encode::Encoder::new(&crate::encode::Settings::new(48_000, Layout::Mono)).unwrap(); + let frames = encoder.frame_size(); + (0..count) + .map(|packet| { + let pcm: Vec = (packet * frames..(packet + 1) * frames) + .map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / 48_000.0).sin() * 0.5) + .collect(); + encoder.encode(&pcm).unwrap().payload + }) + .collect() + } + + /// A catalog shaped like an import's: the OpusHead input rate as the catalog rate. + pub(crate) fn opus_catalog(head: moq_mux::codec::opus::Config) -> hang::catalog::AudioConfig { + let mut catalog = + hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, head.sample_rate, head.channel_count); + catalog.description = Some(head.encode().unwrap()); + catalog + } + + fn rms(samples: &[f32]) -> f32 { + (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt() + } + + /// The OpusHead input rate is metadata: 44.1 kHz and unknown (0) are valid + /// heads, and no input rate changes the 48 kHz clock the packets decode on. + #[test] + fn opus_decodes_at_48k_whatever_the_input_rate() { + let packets = opus_packets(2); + for input_rate in [0, 8_000, 24_000, 44_100, 48_000, 96_000] { + let head = moq_mux::codec::opus::Config::new(input_rate, 1).with_pre_skip(312); + let mut decoder = Decoder::new(&opus_catalog(head), &Config::default()).unwrap(); + assert_eq!(decoder.sample_rate(), 48_000, "input rate {input_rate}"); + + // The pre-skip is 48 kHz samples, trimmed once. + assert_eq!(decoder.decode(&packets[0]).unwrap().samples.len(), 960 - 312); + assert_eq!(decoder.decode(&packets[1]).unwrap().samples.len(), 960); + } + } + + #[test] + fn opus_applies_the_declared_gain() { + let packets = opus_packets(5); + let decode = |output_gain: i16| { + let mut head = moq_mux::codec::opus::Config::new(48_000, 1); + head.output_gain = output_gain; + let mut decoder = Decoder::new(&opus_catalog(head), &Config::default()).unwrap(); + let mut last = Vec::new(); + for packet in &packets { + last = decoder.decode(packet).unwrap().samples; + } + // A reset after loss keeps the gain. + decoder.reset().unwrap(); + let reset = decoder.decode(&packets[4]).unwrap().samples; + (rms(&last), rms(&reset)) + }; + + // -6.02 dB in Q7.8 halves the amplitude. + let (plain, plain_reset) = decode(0); + let (quiet, quiet_reset) = decode(-1541); + assert!((quiet / plain - 0.5).abs() < 0.001, "gain ratio {}", quiet / plain); + assert!( + (quiet_reset / plain_reset - 0.5).abs() < 0.001, + "gain ratio after reset {}", + quiet_reset / plain_reset + ); + } + + /// A present description is the stream's configuration, so a broken one is + /// refused rather than replaced by the catalog's fields. + #[test] + fn opus_refuses_a_malformed_description() { + let valid = moq_mux::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec(); + let mut version = valid.clone(); + version[8] = 16; + let mut channels = valid.clone(); + channels[9] = 3; + let mut signature = valid.clone(); + signature[0] = b'X'; + // Family 1 promising a table that is not there. + let mut table = valid.clone(); + table[18] = 1; + + for (name, description) in [ + ("truncated", valid[..18].to_vec()), + ("empty", Vec::new()), + ("signature", signature), + ("version", version), + ("channels", channels), + ("table", table), + ] { + let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); + catalog.description = Some(description.into()); + assert!( + matches!(Decoder::new(&catalog, &Config::default()), Err(Error::Unsupported(_))), + "{name}" + ); + } + } + + /// Mapping families other than 0 need the multistream decoder; a stereo + /// family 1 head is refused rather than decoded as family 0. + #[test] + fn opus_refuses_unsupported_channel_mappings() { + let valid = moq_mux::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec(); + for family in [1, 255] { + let mut description = valid.clone(); + description[18] = family; + // One coupled stream, left then right. + description.extend_from_slice(&[1, 1, 0, 1]); + + let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); + catalog.description = Some(description.into()); + assert!( + matches!(Decoder::new(&catalog, &Config::default()), Err(Error::Unsupported(_))), + "family {family}" + ); + } + } + + /// Without a description the catalog shapes the stream, which has no pre-skip. + #[test] + fn opus_decodes_without_a_description() { + let packets = opus_packets(1); + let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 24_000, 1); + let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap(); + assert_eq!(decoder.sample_rate(), 48_000); + assert_eq!(decoder.decode(&packets[0]).unwrap().samples.len(), 960); + + let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 6); + assert!(matches!( + Decoder::new(&catalog, &Config::default()), + Err(Error::Unsupported(_)) + )); + } + #[test] fn pcm_rejects_incomplete_channel_frame() { let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2); diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index e166e19f1a..33fc3f428f 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -2041,4 +2041,268 @@ mod tests { assert_eq!(drops.load(Ordering::SeqCst), 1); } + + /// Clock fixtures: the real publication driver, fed by synthetic microphones against a + /// pinned broadcast clock, graded on the timestamps a subscriber reads back. + /// + /// Capture stamps a buffer when the driver reads it, so each expectation is bracketed by + /// the broadcast clock just before the fixture delivers the buffer and just after the + /// published packet is read back. + mod clock { + use std::time::{Duration, Instant, SystemTime}; + + use super::*; + + /// Retain every fixture group, so a slow runner never evicts one before it is read. + const RETAIN: Duration = Duration::from_secs(600); + + type Samples = kio::Queue>; + type Track = moq_mux::container::Consumer; + + struct Fixture { + epoch: Instant, + clock: moq_mux::Clock, + catalog: moq_mux::catalog::Producer, + consumer: moq_net::broadcast::Consumer, + publication: Publication, + task: tokio::task::JoinHandle>, + } + + impl Fixture { + /// Run a publication over `opens` on a broadcast whose clock began `behind` ago, at `wall`. + fn start(behind: Duration, wall: SystemTime, opens: impl IntoIterator) -> Self { + let epoch = Instant::now() + .checked_sub(behind) + .expect("a monotonic clock that far back"); + let clock = moq_mux::Clock::at(epoch, wall).unwrap(); + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let config = moq_mux::catalog::Config::default() + .with_clock(clock) + .with_max_age(RETAIN); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap(); + let mut options = PublicationOptions::default(); + options.encode.track = Some("audio".into()); + // The broadcast's own clock, as `moq import capture` hands it. + options.clock = catalog.clock(); + let (publication, driver) = + Publication::build(broadcast, catalog.clone(), options, Supervisor::exact()).unwrap(); + let task = tokio::spawn(driver.run_with(source(opens, false))); + Self { + epoch, + clock, + catalog, + consumer, + publication, + task, + } + } + + /// Subscribe to the audio track, which is what opens the microphone. + async fn subscribe(&mut self) -> Track { + let track = self + .consumer + .track("audio") + .unwrap() + .subscribe(moq_net::track::Subscription::default().with_max_age(RETAIN)) + .await + .unwrap(); + wait_for(&mut self.publication, Status::Live).await; + moq_mux::container::Consumer::new( + track, + moq_mux::container::legacy::Wire(moq_mux::container::Kind::Audio), + ) + } + + /// `instant` on the broadcast clock, in microseconds. + fn at(&self, instant: Instant) -> u64 { + u64::try_from(instant.duration_since(self.epoch).as_micros()).unwrap() + } + + /// Deliver one Opus frame of stereo audio and read back the first packet not in + /// `seen`, asserting it is stamped while the driver held the buffer. + async fn deliver(&self, samples: &Samples, track: &mut Track, seen: &[u64]) -> u64 { + let pushed = self.at(Instant::now()); + samples + .try_push(Ok(capture::Samples::plain(vec![0.1; 1920], false))) + .unwrap(); + let published = loop { + let packet = track.read().await.unwrap().expect("a published packet"); + let timestamp = u64::try_from(packet.timestamp.as_micros()).unwrap(); + if !seen.contains(×tamp) { + break timestamp; + } + }; + let read = self.at(Instant::now()); + assert!( + (pushed..=read).contains(&published), + "published {published}us, delivered within {pushed}..={read}us on the broadcast clock" + ); + published + } + + /// Stop the publication, as dropping its last control does. + async fn finish(self) -> (moq_mux::catalog::Producer, moq_net::broadcast::Consumer) { + drop(self.publication); + self.task.await.unwrap().unwrap(); + (self.catalog, self.consumer) + } + } + + /// A microphone whose first buffer arrives long after the broadcast began stamps it + /// then, rather than restarting the broadcast at zero. + #[tokio::test] + async fn a_late_first_buffer_publishes_its_arrival() { + let (samples, input) = stream(None); + let mut fixture = Fixture::start(Duration::from_secs(5), SystemTime::now(), [Open::Stream(input)]); + let mut track = fixture.subscribe().await; + + let published = fixture.deliver(&samples, &mut track, &[]).await; + assert!(published >= 5_000_000, "{published}us restarted the broadcast at zero"); + fixture.finish().await; + } + + /// A device that fails and reopens counts its samples from zero again. The broadcast + /// continues forward from the reopen instead of rewinding to the old epoch. + #[tokio::test] + async fn a_device_restart_continues_forward() { + let (first, failing) = stream(None); + let (second, reopened) = stream(None); + let mut fixture = Fixture::start( + Duration::from_secs(1), + SystemTime::now(), + [Open::Stream(failing), Open::Stream(reopened)], + ); + let mut track = fixture.subscribe().await; + + let before = fixture.deliver(&first, &mut track, &[]).await; + first + .try_push(Err(capture::Failure::retry(Error::Capture("unplugged".into())))) + .unwrap(); + wait_for(&mut fixture.publication, Status::Failed).await; + // The supervisor backs off before reopening, so the restart lands at least that late. + let after = fixture.deliver(&second, &mut track, &[before]).await; + let backoff = u64::try_from(RETRY_MIN.as_micros()).unwrap(); + assert!( + after >= before + backoff, + "{after}us rewound or collapsed the {backoff}us backoff" + ); + fixture.finish().await; + } + + /// Releasing the microphone while nobody listens keeps the broadcast clock running: + /// the buffer after a resume lands after the real idle gap. + #[tokio::test] + async fn a_restart_after_idle_keeps_the_gap() { + let idle = Duration::from_millis(300); + let (first, input) = stream(None); + let (second, resumed) = stream(None); + let mut fixture = Fixture::start( + Duration::from_secs(1), + SystemTime::now(), + [Open::Stream(input), Open::Stream(resumed)], + ); + + let mut track = fixture.subscribe().await; + let before = fixture.deliver(&first, &mut track, &[]).await; + drop(track); + wait_for(&mut fixture.publication, Status::Waiting).await; + + tokio::time::sleep(idle).await; + + let mut track = fixture.subscribe().await; + let after = fixture.deliver(&second, &mut track, &[before]).await; + assert!( + after - before >= u64::try_from(idle.as_micros()).unwrap(), + "the {idle:?} idle gap collapsed to {}us", + after - before + ); + fixture.finish().await; + } + + /// The wall mapping is pinned when the broadcast clock is built. A system clock stepped + /// an hour since then retimes neither the published timestamps nor the advertised mapping. + #[tokio::test] + async fn a_system_wall_adjustment_retimes_nothing() { + // Whole seconds, so the advertised mapping holds it exactly. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() - 3600); + let (samples, input) = stream(None); + let mut fixture = Fixture::start(Duration::from_secs(1), wall, [Open::Stream(input)]); + let advertised = fixture.catalog.snapshot().clock; + assert_eq!(advertised, Some(fixture.clock.wall())); + + let mut track = fixture.subscribe().await; + let published = fixture.deliver(&samples, &mut track, &[]).await; + + // The catalog maps to walls at millisecond precision. + let mapped = advertised + .unwrap() + .wall_clock(moq_net::Timestamp::from_micros(published).unwrap()) + .unwrap(); + assert_eq!(mapped, wall + Duration::from_millis(published / 1000)); + assert_eq!(fixture.catalog.snapshot().clock, advertised); + fixture.finish().await; + } + + /// A recording replays what the live edge published: the archive's segment records + /// carry the live timestamps across an idle restart, with the idle gap left in. + #[tokio::test] + async fn retained_archive_playback_keeps_the_live_timestamps() { + let (first, input) = stream(None); + let (second, resumed) = stream(None); + let mut fixture = Fixture::start( + Duration::from_secs(1), + SystemTime::now(), + [Open::Stream(input), Open::Stream(resumed)], + ); + let mut live = Vec::new(); + let mut timeline = None; + + for samples in [&first, &second] { + let mut track = fixture.subscribe().await; + live.push(fixture.deliver(samples, &mut track, &live).await); + // The rendition, and with it the archive, registers once the input is discovered. + if timeline.is_none() { + let section = fixture + .catalog + .snapshot() + .archive + .expect("the audio track enrolls an archive"); + timeline = Some( + moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion) + .await + .unwrap(), + ); + } + drop(track); + wait_for(&mut fixture.publication, Status::Waiting).await; + // Idle past the minimum segment, so each run is archived as its own segment. + tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + } + + let (catalog, _consumer) = fixture.finish().await; + catalog.timeline().finish().unwrap(); + let mut timeline = timeline.unwrap(); + let mut archived = Vec::new(); + while let Some(event) = timeline.next().await.unwrap() { + match event { + moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), + other => panic!("unexpected timeline event {other:?}"), + } + } + + assert_eq!(archived.len(), live.len(), "one segment per capture run: {archived:?}"); + for (entry, live) in archived.iter().zip(&live) { + // The archive keeps millisecond precision. + assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); + assert!(entry.tracks.contains_key("audio"), "{archived:?}"); + } + let first = &archived[0]; + assert!( + archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), + "the resumed segment overlaps the one before it: {archived:?}" + ); + } + } } diff --git a/rs/moq-audio/src/error.rs b/rs/moq-audio/src/error.rs index 260efa2ee2..9eb58b7fb3 100644 --- a/rs/moq-audio/src/error.rs +++ b/rs/moq-audio/src/error.rs @@ -5,8 +5,9 @@ #[derive(Clone, Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { - /// The requested configuration is outside what the codec supports, e.g. a - /// sample rate, channel count, or frame duration Opus can't encode. The + /// The requested configuration is outside what the codec or device supports, + /// e.g. a sample rate, channel count, or frame duration Opus can't encode, or a + /// capture format the microphone can't deliver. The /// caller asked for something impossible; picking different settings fixes it. #[error("unsupported audio configuration: {0}")] Unsupported(String), diff --git a/rs/moq-audio/src/opus.rs b/rs/moq-audio/src/opus.rs index d87a6055d3..c8b2e8fac2 100644 --- a/rs/moq-audio/src/opus.rs +++ b/rs/moq-audio/src/opus.rs @@ -14,6 +14,34 @@ const RATES: [u32; 5] = [8_000, 12_000, 16_000, 24_000, 48_000]; /// Frame durations libopus accepts, in microseconds. const FRAME_DURATIONS: [u128; 6] = [2_500, 5_000, 10_000, 20_000, 40_000, 60_000]; +/// The rate every Opus stream decodes at: the codec's clock, in which pre-skip +/// and packet durations are reckoned. The OpusHead input rate is metadata. +pub(crate) const DECODE_RATE: u32 = 48_000; + +/// The OpusHead a decoder opens with. +/// +/// The catalog's description when present, refused when malformed or when it +/// asks for a channel mapping this crate cannot decode. Without one, a +/// mono/stereo stream with no pre-skip or gain, shaped by the catalog. +pub(crate) fn head(catalog: &hang::catalog::AudioConfig) -> Result { + let Some(description) = &catalog.description else { + return Ok(moq_mux::codec::opus::Config::new( + catalog.sample_rate, + catalog.channel_count, + )); + }; + + let head = moq_mux::codec::opus::Config::parse(&mut description.as_ref()) + .map_err(|err| Error::Unsupported(format!("opus description: {err}")))?; + if head.mapping_family != 0 { + return Err(Error::Unsupported(format!( + "opus channel mapping family {} is not supported", + head.mapping_family + ))); + } + Ok(head) +} + /// Snap an arbitrary sample rate up to the nearest libopus-supported rate; /// falls back to 48 kHz for anything above the highest. pub(crate) fn pick_rate(input_rate: u32) -> u32 { diff --git a/rs/moq-auth/CHANGELOG.md b/rs/moq-auth/CHANGELOG.md index 9edf279fd5..4cb4d45f86 100644 --- a/rs/moq-auth/CHANGELOG.md +++ b/rs/moq-auth/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.1...moq-auth-v0.1.2) - 2026-09-25 + +### Fixed + +- *(auth)* read and write legacy put/get token grants ([#4190](https://github.com/moq-dev/moq/pull/4190)) + ## [0.1.1](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.0...moq-auth-v0.1.1) - 2026-09-25 ### Added diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 5c5020bd2a..f5af478976 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.1" +version = "0.1.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-auth/src/claims.rs b/rs/moq-auth/src/claims.rs index c6cacef28d..d49a252f1a 100644 --- a/rs/moq-auth/src/claims.rs +++ b/rs/moq-auth/src/claims.rs @@ -1,7 +1,6 @@ use crate::path; use moq_pattern::Patterns; use serde::{Deserialize, Serialize}; -use serde_with::{TimestampSeconds, serde_as}; /// The immutable ceiling on what a key may grant, embedded in its JWK. /// @@ -13,19 +12,19 @@ use serde_with::{TimestampSeconds, serde_as}; /// is the point: a leaked scoped key can never be talked into signing more than it /// already could. A key with no scope at all is unrestricted, so keys minted before /// scopes existed keep working. +/// +/// Legacy `put`/`get` prefix scopes load as subtree patterns, and a scope that only +/// grants subtrees is written that way so older readers load it too. #[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)] -#[serde(default, deny_unknown_fields)] +#[serde(try_from = "crate::wire::Scope", into = "crate::wire::Scope")] pub struct Scope { /// The root for the publish/subscribe patterns below. - #[serde(skip_serializing_if = "String::is_empty")] pub root: String, /// Patterns this key may grant to publishers. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub publish: Patterns, /// Patterns this key may grant to subscribers. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub subscribe: Patterns, } @@ -103,37 +102,30 @@ impl Permissions { /// .with_subscribe(["**".parse().unwrap()]); /// ``` /// -/// Any other field, including the retired `put` and `get` prefix lists, fails -/// verification: a token either speaks patterns or it is not one of ours. -#[serde_with::skip_serializing_none] -#[serde_as] +/// 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. #[derive(Debug, Serialize, Deserialize, Default, Clone)] -#[serde(default, deny_unknown_fields)] +#[serde(try_from = "crate::wire::Claims", into = "crate::wire::Claims")] #[non_exhaustive] pub struct Claims { /// The root for the publish/subscribe patterns below. /// It's mostly for compression and is optional, defaulting to the empty string. - #[serde(skip_serializing_if = "String::is_empty")] pub root: String, /// If specified, the user can publish any matching broadcasts. /// If not specified, the user will not publish any broadcasts. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub publish: Patterns, /// If specified, the user can subscribe to any matching broadcasts. /// If not specified, the user will not receive announcements and cannot subscribe to any broadcasts. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub subscribe: Patterns, - /// The expiration time of the token as a unix timestamp. - #[serde(rename = "exp")] - #[serde_as(as = "Option>")] + /// The expiration time of the token as a unix timestamp (`exp`). pub expires: Option, - /// The issued time of the token as a unix timestamp. - #[serde(rename = "iat")] - #[serde_as(as = "Option>")] + /// The issued time of the token as a unix timestamp (`iat`). pub issued: Option, } @@ -400,9 +392,38 @@ mod tests { } #[test] - fn scope_refuses_the_old_prefix_fields() { - let err = serde_json::from_str::(r#"{"root":"demo","put":["room"]}"#).unwrap_err(); - assert!(err.to_string().contains("unknown field `put`"), "{err}"); + fn scope_refuses_null_grants() { + assert!(serde_json::from_str::(r#"{"put":null,"publish":["room"]}"#).is_err()); + } + + #[test] + fn scope_reads_legacy_prefixes_as_subtrees() { + let scope: Scope = serde_json::from_str(r#"{"root":"demo","put":["room"],"get":[""]}"#).unwrap(); + assert_eq!(scope.publish, patterns(&["room/**"])); + assert_eq!(scope.subscribe, patterns(&["**"])); + } + + #[test] + fn scope_writes_legacy_prefixes_only_when_faithful() { + let subtrees = Scope { + root: "demo".into(), + publish: patterns(&["room/**"]), + subscribe: patterns(&["**"]), + }; + assert_eq!( + serde_json::to_string(&subtrees).unwrap(), + r#"{"root":"demo","put":["room"],"get":[""]}"# + ); + + let exact = Scope { + root: "demo".into(), + publish: patterns(&["room"]), + subscribe: Patterns::new(), + }; + assert_eq!( + serde_json::to_string(&exact).unwrap(), + r#"{"root":"demo","publish":["room"]}"# + ); } #[test] @@ -476,17 +497,61 @@ mod tests { } #[test] - fn test_claims_refuse_the_old_prefix_fields() { + fn test_claims_read_legacy_prefixes_as_subtrees() { + let claims: Claims = + serde_json::from_str(r#"{"root":"test","put":["pub1","/a//b/"],"get":"","exp":1700000000}"#).unwrap(); + assert_eq!(claims.publish, patterns(&["pub1/**", "a/b/**"])); + assert_eq!(claims.subscribe, patterns(&["**"])); + assert!(claims.expires.is_some()); + } + + #[test] + fn test_claims_write_legacy_prefixes_only_when_faithful() { + // Every grant is a subtree, so the legacy form says exactly the same thing. + let subtrees = Claims { + root: "live".into(), + publish: patterns(&["camera1/**"]), + subscribe: patterns(&["**"]), + ..Default::default() + }; + let json = serde_json::to_string(&subtrees).unwrap(); + assert_eq!(json, r#"{"root":"live","put":["camera1"],"get":[""]}"#); + let back: Claims = serde_json::from_str(&json).unwrap(); + assert_eq!(back.publish, subtrees.publish); + assert_eq!(back.subscribe, subtrees.subscribe); + + // One grant a prefix can't say moves the whole document to patterns. + let mixed = Claims { + root: "live".into(), + publish: patterns(&["camera1/**"]), + subscribe: patterns(&["*/chat"]), + ..Default::default() + }; + assert_eq!( + serde_json::to_string(&mixed).unwrap(), + r#"{"root":"live","publish":["camera1/**"],"subscribe":["*/chat"]}"# + ); + } + + #[test] + fn test_claims_refuse_mixed_or_unknown_fields() { for json in [ - r#"{"root":"test","put":["pub1"]}"#, - r#"{"root":"test","get":"sub1"}"#, r#"{"root":"test","publish":["pub1"],"get":["sub1"]}"#, + r#"{"root":"test","put":[],"subscribe":["sub1"]}"#, + r#"{"root":"test","put":["pub1"],"cluster":true}"#, + r#"{"root":"test","put":null,"publish":["pub1"]}"#, + r#"{"root":"test","publish":null,"subscribe":["sub1"]}"#, ] { - let err = serde_json::from_str::(json).unwrap_err(); - assert!(err.to_string().contains("unknown field"), "{json}: {err}"); + assert!(serde_json::from_str::(json).is_err(), "{json}"); } } + #[test] + fn test_claims_refuse_a_wildcard_in_a_legacy_prefix() { + // Legacy prefixes had no wildcards; a `*` would silently widen the grant. + assert!(serde_json::from_str::(r#"{"put":["a/*"]}"#).is_err()); + } + #[test] fn test_claims_refuse_a_bad_pattern() { let err = serde_json::from_str::(r#"{"publish":["a/**/b/**"]}"#).unwrap_err(); diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index 5b62829a97..2f22551de0 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -202,7 +202,7 @@ impl Client { mod tests { use super::*; use moq_pattern::Patterns; - use std::sync::{Arc, Mutex}; + use std::task::Poll; use std::time::SystemTime; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, Request as Received, ResponseTemplate}; @@ -219,21 +219,38 @@ mod tests { /// Records every request body the server saw, in order. #[derive(Clone, Default)] - struct Log(Arc>>); + struct Log(kio::Shared>); impl Log { fn events(&self) -> Vec { - self.0.lock().unwrap().iter().map(|r| r.event.clone()).collect() + self.0.read().iter().map(|r| r.event.clone()).collect() } - fn last(&self) -> Request { - self.0.lock().unwrap().last().cloned().unwrap() + fn revalidates(&self) -> usize { + self.events() + .iter() + .filter(|event| **event == Event::Revalidate) + .count() + } + + /// Wait until the requests seen so far satisfy `done`. + async fn until(&self, mut done: impl FnMut(&[Request]) -> bool + Unpin) { + self.0 + .wait(|log| if done(log) { Poll::Ready(()) } else { Poll::Pending }) + .await; + } + + /// Wait for the background `end` POST and return it. + async fn end(&self) -> Request { + let is_end = |r: &Request| matches!(r.event, Event::End { .. }); + self.until(|log| log.iter().any(is_end)).await; + self.0.read().iter().find(|r| is_end(r)).cloned().unwrap() } } impl wiremock::Match for Log { fn matches(&self, received: &Received) -> bool { - self.0.lock().unwrap().push(received.body_json().unwrap()); + self.0.lock().push(received.body_json().unwrap()); true } } @@ -262,10 +279,6 @@ mod tests { grant } - async fn settle() { - tokio::time::sleep(Duration::from_millis(50)).await; - } - #[tokio::test] async fn connect_admits_and_end_follows_the_close() { let log = Log::default(); @@ -279,9 +292,8 @@ mod tests { assert_eq!(log.events(), [Event::Connect]); consumer.close("disconnected", Bytes { sent: 7, received: 11 }); - settle().await; - let end = log.last(); + let end = log.end().await; assert_eq!(end.id, "0123"); match end.event { Event::End { reason, bytes, .. } => { @@ -302,9 +314,8 @@ mod tests { let consumer = client(&server).connect(request()).await.unwrap(); drop(consumer); - settle().await; - match log.last().event { + match log.end().await.event { Event::End { reason: Reason::Dropped, bytes, @@ -436,10 +447,7 @@ mod tests { let consumer = client(&server).connect(request()).await.unwrap(); tokio::time::sleep(Duration::from_millis(1500)).await; - assert!( - log.events().iter().filter(|e| **e == Event::Revalidate).count() >= 1, - "re-checks happened" - ); + assert!(log.revalidates() >= 1, "re-checks happened"); assert_eq!( consumer.grant().publish, patterns(&["**"]), @@ -450,9 +458,8 @@ mod tests { .await .expect("expired"); assert_eq!(reason, Reason::Expired); - settle().await; assert!(matches!( - log.last().event, + log.end().await.event, Event::End { reason: Reason::Expired, .. @@ -495,9 +502,8 @@ mod tests { tokio::time::sleep(Duration::from_millis(1500)).await; assert!(log.events().contains(&Event::Revalidate), "the re-check is in flight"); consumer.close("disconnected", Bytes::default()); - settle().await; assert!(matches!( - log.last().event, + log.end().await.event, Event::End { reason: Reason::Session(_), .. @@ -523,6 +529,8 @@ mod tests { assert!(Client::new("unix:///run/moq-auth.sock".parse().unwrap(), None).is_ok()); } + /// Backoff leaves the driver in this same state (nothing in flight, a timer armed), + /// so this covers a nudge during backoff too. #[tokio::test] async fn a_nudge_while_idle_posts_at_once() { let log = Log::default(); @@ -535,56 +543,14 @@ mod tests { let consumer = client(&server).connect(request()).await.unwrap(); assert_eq!(log.events(), [Event::Connect]); consumer.revalidate(); - tokio::time::timeout(Duration::from_secs(2), async { - loop { - if log.events().contains(&Event::Revalidate) { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) + tokio::time::timeout( + Duration::from_secs(2), + log.until(|log| log.iter().any(|r| r.event == Event::Revalidate)), + ) .await .expect("a nudge while idle POSTs at once"); } - #[tokio::test] - async fn a_nudge_during_backoff_posts_at_once() { - let log = Log::default(); - let server = server(log.clone(), |request| match request.event { - Event::Connect => ResponseTemplate::new(200) - .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(3600)))), - _ => ResponseTemplate::new(503), - }) - .await; - - let consumer = client(&server).connect(request()).await.unwrap(); - consumer.revalidate(); - tokio::time::timeout(Duration::from_secs(2), async { - loop { - if log.events().contains(&Event::Revalidate) { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the first re-check ran"); - settle().await; - let before = log.events().iter().filter(|event| **event == Event::Revalidate).count(); - - consumer.revalidate(); - tokio::time::timeout(Duration::from_secs(2), async { - loop { - if log.events().iter().filter(|event| **event == Event::Revalidate).count() > before { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("a nudge during backoff POSTs at once"); - } - #[tokio::test] async fn a_nudge_during_inflight_posts_once_more_when_the_reply_lands() { let log = Log::default(); @@ -600,32 +566,27 @@ mod tests { let consumer = client(&server).connect(request()).await.unwrap(); consumer.revalidate(); - tokio::time::timeout(Duration::from_secs(2), async { - loop { - if log.events().contains(&Event::Revalidate) { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) + tokio::time::timeout( + Duration::from_secs(2), + log.until(|log| log.iter().any(|r| r.event == Event::Revalidate)), + ) .await .expect("the first re-check is in flight"); consumer.revalidate(); consumer.revalidate(); - tokio::time::timeout(Duration::from_secs(3), async { - loop { - if log.events().iter().filter(|event| **event == Event::Revalidate).count() >= 2 { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) + // `changed` jumps to the latest grant epoch, so two replies can arrive as one + // observation. The request log does not coalesce. + tokio::time::timeout( + Duration::from_secs(3), + log.until(|log| log.iter().filter(|r| r.event == Event::Revalidate).count() >= 2), + ) .await .expect("the in-flight nudge POSTs once more when the reply lands"); - settle().await; + consumer.close("disconnected", Bytes::default()); + log.end().await; assert_eq!( - log.events().iter().filter(|event| **event == Event::Revalidate).count(), + log.revalidates(), 2, "a burst during an in-flight re-check is one extra POST" ); diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index cf43bcf7f3..260b0a462b 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -1634,11 +1634,40 @@ mod tests { } #[test] - fn test_js_legacy_prefix_token_is_refused() { - // The signature is fine; the claims speak prefixes, which is no longer a token. + fn test_js_legacy_prefix_token_verifies_as_subtrees() { let key = Key::from_str(JS_HS256_KEY).unwrap(); - let err = key.verify(JS_HS256_LEGACY_TOKEN).unwrap_err(); - assert!(err.to_string().contains("unknown field"), "{err}"); + let claims = key.verify(JS_HS256_LEGACY_TOKEN).unwrap(); + assert_eq!(claims.root, "live"); + assert_eq!(claims.publish, patterns(&["camera1/**"])); + assert_eq!(claims.subscribe, patterns(&["camera1/**", "camera2/**"])); + } + + #[test] + fn test_legacy_scoped_key_signs_within_its_prefixes() { + // A key minted by moq-token-cli with `--root demo --put room`. + let json = r#"{"kty":"oct","alg":"HS256","key_ops":["sign","verify"],"k":"Fp8kipWUJeUFqeSqWym_tRC_tyI8z-QpqopIGrbrD68","scope":{"root":"demo","put":["room"]}}"#; + let key = Key::from_str(json).unwrap(); + assert_eq!(key.scope.as_ref().unwrap().publish, patterns(&["room/**"])); + + let inside = Claims { + root: "demo/room".into(), + publish: patterns(&["alice"]), + ..Default::default() + }; + let outside = Claims { + root: "demo".into(), + publish: patterns(&["lobby/**"]), + ..Default::default() + }; + assert!(key.verify(&key.sign(&inside).unwrap()).is_ok()); + assert!(matches!(key.sign(&outside), Err(crate::Error::ScopeExceeded))); + + // Written back the way it was read, so the old CLI still loads it. + assert!( + serde_json::to_string(&key) + .unwrap() + .contains(r#""scope":{"root":"demo","put":["room"]}"#) + ); } #[test] diff --git a/rs/moq-auth/src/lib.rs b/rs/moq-auth/src/lib.rs index 486aae7fdb..03c34f76c2 100644 --- a/rs/moq-auth/src/lib.rs +++ b/rs/moq-auth/src/lib.rs @@ -27,6 +27,7 @@ mod key_id; mod path; mod request; mod set; +mod wire; pub mod lease; #[cfg(feature = "serve")] diff --git a/rs/moq-auth/src/wire.rs b/rs/moq-auth/src/wire.rs new file mode 100644 index 0000000000..c7fedc14ef --- /dev/null +++ b/rs/moq-auth/src/wire.rs @@ -0,0 +1,196 @@ +//! The JSON encoding of the grants in [`Claims`](crate::Claims) and [`Scope`](crate::Scope). +//! +//! Grants were once prefix lists named `put` and `get`, and every published `moq-token` +//! reader still expects them. A prefix `p` means exactly the pattern `p/**` (and `""` +//! means `**`), so grants that are all subtrees are written the old way and every +//! reader, old or new, agrees on what they grant. Anything a prefix can't say is written +//! as `publish` and `subscribe`, which an old reader sees as granting nothing and +//! refuses. Both encodings are read; one document never mixes them. + +use moq_pattern::{Pattern, Patterns}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{TimestampSeconds, serde_as}; + +/// The wire form of [`Claims`](crate::Claims). +#[serde_with::skip_serializing_none] +#[serde_as] +#[derive(Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Claims { + #[serde(skip_serializing_if = "String::is_empty")] + root: String, + #[serde(deserialize_with = "present")] + put: Option, + #[serde(deserialize_with = "present")] + get: Option, + #[serde(deserialize_with = "present")] + publish: Option, + #[serde(deserialize_with = "present")] + subscribe: Option, + #[serde_as(as = "Option>")] + exp: Option, + #[serde_as(as = "Option>")] + iat: Option, +} + +impl From for Claims { + fn from(claims: crate::Claims) -> Self { + let grants = Grants::encode(claims.publish, claims.subscribe); + Self { + root: claims.root, + put: grants.put.map(Prefixes::Many), + get: grants.get.map(Prefixes::Many), + publish: grants.publish, + subscribe: grants.subscribe, + exp: claims.expires, + iat: claims.issued, + } + } +} + +impl TryFrom for crate::Claims { + type Error = String; + + fn try_from(wire: Claims) -> Result { + let grants = Grants { + put: wire.put.map(Prefixes::into_vec), + get: wire.get.map(Prefixes::into_vec), + publish: wire.publish, + subscribe: wire.subscribe, + }; + let (publish, subscribe) = grants.decode()?; + Ok(Self { + root: wire.root, + publish, + subscribe, + expires: wire.exp, + issued: wire.iat, + }) + } +} + +/// The wire form of [`Scope`](crate::Scope). Legacy scopes only ever held lists. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Scope { + #[serde(skip_serializing_if = "String::is_empty")] + root: String, + #[serde(deserialize_with = "present")] + put: Option>, + #[serde(deserialize_with = "present")] + get: Option>, + #[serde(deserialize_with = "present")] + publish: Option, + #[serde(deserialize_with = "present")] + subscribe: Option, +} + +impl From for Scope { + fn from(scope: crate::Scope) -> Self { + let grants = Grants::encode(scope.publish, scope.subscribe); + Self { + root: scope.root, + put: grants.put, + get: grants.get, + publish: grants.publish, + subscribe: grants.subscribe, + } + } +} + +impl TryFrom for crate::Scope { + type Error = String; + + fn try_from(wire: Scope) -> Result { + let grants = Grants { + put: wire.put, + get: wire.get, + publish: wire.publish, + subscribe: wire.subscribe, + }; + let (publish, subscribe) = grants.decode()?; + Ok(Self { + root: wire.root, + publish, + subscribe, + }) + } +} + +/// Legacy claims wrote a single prefix as a bare string. +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum Prefixes { + One(String), + Many(Vec), +} + +impl Prefixes { + fn into_vec(self) -> Vec { + match self { + Self::One(prefix) => vec![prefix], + Self::Many(prefixes) => prefixes, + } + } +} + +/// A present field, refusing an explicit `null` rather than reading it as absent, +/// so a `null` can't hide one encoding from the mixed-encoding check. +fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result, D::Error> { + T::deserialize(deserializer).map(Some) +} + +/// The grant fields shared by both documents, in whichever encoding they arrived. +struct Grants { + put: Option>, + get: Option>, + publish: Option, + subscribe: Option, +} + +impl Grants { + fn encode(publish: Patterns, subscribe: Patterns) -> Self { + let prefixes = |patterns: &Patterns| -> Option> { + patterns + .iter() + .map(|pattern| pattern.as_prefix().map(str::to_string)) + .collect() + }; + let some = |patterns: Patterns| (!patterns.is_empty()).then_some(patterns); + + match (prefixes(&publish), prefixes(&subscribe)) { + (Some(put), Some(get)) => Self { + put: (!put.is_empty()).then_some(put), + get: (!get.is_empty()).then_some(get), + publish: None, + subscribe: None, + }, + _ => Self { + put: None, + get: None, + publish: some(publish), + subscribe: some(subscribe), + }, + } + } + + fn decode(self) -> Result<(Patterns, Patterns), String> { + let legacy = self.put.is_some() || self.get.is_some(); + if !legacy { + return Ok((self.publish.unwrap_or_default(), self.subscribe.unwrap_or_default())); + } + if self.publish.is_some() || self.subscribe.is_some() { + return Err("mixes the legacy put/get fields with publish/subscribe".to_string()); + } + + let subtrees = |prefixes: Option>| -> Result { + prefixes + .unwrap_or_default() + .iter() + .map(|prefix| Pattern::subtree(prefix).map_err(|err| format!("legacy prefix {prefix:?}: {err}"))) + .collect() + }; + Ok((subtrees(self.put)?, subtrees(self.get)?)) + } +} diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index f3d3799e9c..122a0ff8ca 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.4](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.3...moq-binary-v0.1.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.2...moq-binary-v0.1.3) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.1...moq-binary-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index 7e1ea74f2f..85dd3a5d71 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.2" +version = "0.1.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index 9cd4be3f72..3bfe971696 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.5](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.4...moq-boy-v0.5.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux, moq-tokio, moq-audio, moq-video, moq-json, hang + +## [0.5.4](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.3...moq-boy-v0.5.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json, hang, moq-mux, moq-tokio, moq-audio, moq-video + ## [0.5.3](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.2...moq-boy-v0.5.3) - 2026-09-25 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index e8f265bb0d..1b260e84e4 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.3" +version = "0.5.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 29815ec3cf..40cbbbf6c9 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.5](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.4...moq-cli-v0.12.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-mux, moq-tokio, moq-audio, moq-video, moq-auth, hang, moq-hls, moq-relay, moq-rtc, moq-rtmp, moq-srt, moq-transcode + +## [0.12.4](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.3...moq-cli-v0.12.4) - 2026-09-25 + +### Other + +- updated the following local packages: hang, moq-mux, moq-tokio, moq-audio, moq-video, moq-hls, moq-relay, moq-rtc, moq-rtmp, moq-srt, moq-transcode + ## [0.12.3](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.2...moq-cli-v0.12.3) - 2026-09-25 ### Added diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index de9b25aa66..032ef22831 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.3" +version = "0.12.5" 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/play/fake.rs b/rs/moq-cli/src/play/fake.rs new file mode 100644 index 0000000000..ba30cc198d --- /dev/null +++ b/rs/moq-cli/src/play/fake.rs @@ -0,0 +1,143 @@ +//! A deviceless [`Output`] for tests: it records what the speaker would have +//! played and when, on tokio's clock, so a paused runtime measures playback +//! without waiting on it. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use moq_audio::playback::Input; +use tokio::time::Instant; + +use super::output::{Output, Sink, Speaker}; +use super::window::Event; + +/// One write, as the speaker plays it. +#[derive(Clone, Copy, Debug)] +pub(super) struct Played { + /// Which sink it went through, in the order they were opened. + pub sink: usize, + /// Its first sample, which tells a test's renditions apart. + pub sample: f32, + /// When it starts sounding. + pub from: Instant, + /// When it stops sounding, which is earlier than its length if the sink was + /// dropped first. + pub to: Instant, +} + +/// The window and speaker in one, recording instead of presenting. +#[derive(Clone, Default)] +pub(super) struct Recorder { + state: Arc>, +} + +#[derive(Default)] +struct State { + played: Vec, + sinks: usize, + events: Vec, +} + +impl Recorder { + /// Everything the speaker played, in write order. + pub fn played(&self) -> Vec { + self.state.lock().unwrap().played.clone() + } + + /// Take the events the window has been sent so far. + pub fn events(&self) -> Vec { + std::mem::take(&mut self.state.lock().unwrap().events) + } +} + +impl Output for Recorder { + type Speaker = Self; + + async fn speaker(&self) -> anyhow::Result { + Ok(self.clone()) + } + + fn send(&self, event: Event) { + self.state.lock().unwrap().events.push(event); + } +} + +impl Speaker for Recorder { + type Sink = FakeSink; + + fn sink(&self, input: Input) -> anyhow::Result { + anyhow::ensure!(input.format == moq_audio::Format::F32, "the fake sink only reads f32"); + let id = { + let mut state = self.state.lock().unwrap(); + state.sinks += 1; + state.sinks - 1 + }; + Ok(FakeSink { + state: self.state.clone(), + id, + stride: input.layout.channels() as usize * size_of::(), + sample_rate: input.sample_rate, + latency: input.latency, + // A real sink's ring starts out holding its latency in silence, which is + // why the first sample written sounds that much later. + end: Instant::now() + input.latency, + }) + } +} + +/// A speaker draining in real time from the moment the sink opens. +pub(super) struct FakeSink { + state: Arc>, + id: usize, + stride: usize, + sample_rate: u32, + latency: Duration, + /// When everything written so far has played. + end: Instant, +} + +impl Sink for FakeSink { + fn write(&mut self, samples: &[u8]) -> anyhow::Result<()> { + anyhow::ensure!(samples.len().is_multiple_of(self.stride), "misaligned write"); + let now = Instant::now(); + // The real ring pads an underflow back up to its latency in silence once it + // runs down to a quarter of it, rather than playing each late write the + // instant it lands. + if self.buffered() <= self.latency / 4 { + self.end = now + self.latency; + } + + let frames = (samples.len() / self.stride) as u64; + let duration = Duration::from_nanos(frames * 1_000_000_000 / self.sample_rate as u64); + let sample = samples + .first_chunk::<4>() + .map(|bytes| f32::from_le_bytes(*bytes)) + .unwrap_or_default(); + self.state.lock().unwrap().played.push(Played { + sink: self.id, + sample, + from: self.end, + to: self.end + duration, + }); + self.end += duration; + Ok(()) + } + + fn buffered(&self) -> Duration { + self.end.saturating_duration_since(Instant::now()) + } +} + +impl Drop for FakeSink { + /// A dropped sink leaves the mix, cutting off whatever it still held. + fn drop(&mut self) { + let now = Instant::now(); + let mut state = self.state.lock().unwrap(); + state.played.retain_mut(|played| { + if played.sink == self.id { + played.to = played.to.min(now); + } + played.from < played.to + }); + } +} diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index 0c12887e13..70428163ee 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -3,15 +3,17 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::Context; use hang::moq_net; -use moq_audio::playback::{Engine, Sink}; use moq_mux::catalog::{self, Stream}; -use winit::event_loop::EventLoopProxy; +// tokio's clock, which is the wall clock unless a test pauses it to drive the +// playout clock itself. +use tokio::time::Instant; use super::args::Args; +use super::output::{Output, Sink, Speaker}; use super::playback::{Kind, Playback, joined}; use super::source::subscribe; use super::timeline::{AudioTimeline, Presentation, timestamp}; @@ -44,24 +46,24 @@ const AUDIO_CHUNK: Duration = Duration::from_millis(20); const AUDIO_DRAIN_GRACE: Duration = Duration::from_secs(1); /// Everything the media task needs to fill the window and the speaker. -pub(super) struct Media { +pub(super) struct Media { pub(super) origin: moq_net::origin::Consumer, pub(super) broadcast: String, pub(super) args: Args, pub(super) video: Arc>>, pub(super) presentation: Arc>, pub(super) drained: Arc, - pub(super) proxy: EventLoopProxy, + pub(super) output: O, } -impl Media { +impl Media { pub(super) async fn run(self) { - let proxy = self.proxy.clone(); + let output = self.output.clone(); let event = match self.play().await { Ok(()) => Event::Ended, Err(err) => Event::Failed(format!("{err:#}")), }; - let _ = proxy.send_event(event); + output.send(event); } async fn play(self) -> anyhow::Result<()> { @@ -74,13 +76,18 @@ impl Media { .await .context("failed to subscribe to the catalog")?; let mut catalogs = catalog.select(self.args.select.selection(None)); + // The floored depth, not the raw delay: the speaker holds at least + // AUDIO_BUFFER_MIN whatever was asked for, so a smaller budget would skip a + // group the playhead could still have reached, and would size the hole + // fill in `play_audio` to a playhead that does not exist. + let depth = self.args.delay.into_std().max(AUDIO_BUFFER_MIN); let mut tasks = tokio::task::JoinSet::new(); let mut playback = Playback::default(); // Shared by an audio rendition and the retired tails still playing beside // it, so their sinks mix on one stream: a second stream on an exclusive // device would fail to open. Released once none of them is left, so an // idle `play` does not hold the device. - let mut engine = None; + let mut speaker = None; // Retired audio sinks still playing out what they hold. let mut tails = tokio::task::JoinSet::new(); @@ -110,7 +117,7 @@ impl Media { // the other, a rendition switch costs that delay in silence, so the // tail plays out while the replacement fills. if let Some(sink) = sink { - tails.spawn(drain(sink)); + tails.spawn(drain(sink, depth)); } kind }); @@ -135,10 +142,10 @@ impl Media { } } - // The engine is only open while some audio is, so releasing it marks the + // The speaker is only open while some audio is, so releasing it marks the // last of it going quiet: nothing holds playback to the speaker's cadence // any more, and video takes the anchor back. - if !playback.playing(Kind::Audio) && tails.is_empty() && engine.take().is_some() { + if !playback.playing(Kind::Audio) && tails.is_empty() && speaker.take().is_some() { self.presentation.lock().unwrap().stopped(); } @@ -180,11 +187,11 @@ impl Media { let presentation = self.presentation.clone(); let video = self.video.clone(); let drained = self.drained.clone(); - let proxy = self.proxy.clone(); + let output = self.output.clone(); tasks.spawn(async move { ( Kind::Video, - play_video(consumer, presentation, video, drained, proxy) + play_video(consumer, presentation, video, drained, output) .await .map(|()| None), ) @@ -211,11 +218,6 @@ impl Media { continue; } }; - // The floored depth, not the raw delay: the speaker holds at least - // AUDIO_BUFFER_MIN whatever was asked for, so a smaller budget would - // skip a group the playhead could still have reached, and would size - // the hole fill below to a playhead that does not exist. - let depth = self.args.delay.into_std().max(AUDIO_BUFFER_MIN); let mut decode = moq_audio::decode::Options::new(); decode.start = moq_audio::decode::Start::Latest; decode.max_age = depth; @@ -225,14 +227,14 @@ impl Media { match moq_audio::decode::Consumer::new(&rendition, &config, &name, decode).await { Ok(consumer) => { tracing::info!(track = name, "playing audio rendition"); - if engine.is_none() { - engine = Some(Engine::open(Default::default()).await?); + if speaker.is_none() { + speaker = Some(self.output.speaker().await?); } let audio = AudioPlayback { - engine: engine.clone().expect("opened above"), + speaker: speaker.clone().expect("opened above"), presentation: self.presentation.clone(), depth, - proxy: self.proxy.clone(), + output: self.output.clone(), }; tasks.spawn(async move { (Kind::Audio, play_audio(consumer, audio).await.map(Some)) }); playback.started(Kind::Audio); @@ -257,12 +259,12 @@ impl Media { } } -async fn play_video( +async fn play_video( mut consumer: moq_video::decode::Consumer, presentation: Arc>, video: Arc>>, drained: Arc, - proxy: EventLoopProxy, + output: O, ) -> anyhow::Result<()> { while let Some(frame) = consumer.read().await? { // Fold the arrival into the playout clock before queueing it, so the window @@ -270,8 +272,12 @@ async fn play_video( // wake it before the wait below, not after: the window is asleep on the old // anchor's deadline, and it is the only thing that drains the queue this // task is about to block on. - if presentation.lock().unwrap().video(frame.timestamp, Instant::now()) { - let _ = proxy.send_event(Event::Wake); + if presentation + .lock() + .unwrap() + .video(frame.timestamp, Instant::now().into_std()) + { + output.send(Event::Wake); } // Wait for room rather than dropping the oldest. Audio is paced to real @@ -284,26 +290,29 @@ async fn play_video( } video.lock().unwrap().push_back(frame); - let _ = proxy.send_event(Event::Wake); + output.send(Event::Wake); } Ok(()) } -struct AudioPlayback { - engine: Engine, +struct AudioPlayback { + speaker: O::Speaker, presentation: Arc>, depth: Duration, - proxy: EventLoopProxy, + output: O, } /// Play a track until it ends, handing back the sink with the delay it still /// holds. -async fn play_audio(mut consumer: moq_audio::decode::Consumer, playback: AudioPlayback) -> anyhow::Result { +async fn play_audio( + mut consumer: moq_audio::decode::Consumer, + playback: AudioPlayback, +) -> anyhow::Result<::Sink> { let AudioPlayback { - engine, + speaker, presentation, depth, - proxy, + output, } = playback; // `depth` is how much the speaker holds: the playout delay, floored, and the @@ -320,7 +329,7 @@ async fn play_audio(mut consumer: moq_audio::decode::Consumer, playback: AudioPl input.sample_rate = sample_rate; input.layout = layout; input.latency = depth; - let mut sink = engine.sink(input.clone())?; + let mut sink = speaker.sink(input.clone())?; // One sample across every channel, the unit a write has to stay aligned to. let stride = channels as usize * size_of::(); @@ -370,7 +379,7 @@ async fn play_audio(mut consumer: moq_audio::decode::Consumer, playback: AudioPl if timing.reset_sink { drop(sink); presentation.lock().unwrap().restarted(); - sink = engine.sink(input.clone())?; + sink = speaker.sink(input.clone())?; } // A hole in the media is a hole in the audio, not a splice. Handing the next @@ -386,9 +395,7 @@ async fn play_audio(mut consumer: moq_audio::decode::Consumer, playback: AudioPl tokio::time::sleep(excess).await; } let part = remaining.min(silence.len()); - // Playback drops stay on the live timeline; retrying them would add - // latency, and the sink already reports them in its logs. - let _ = sink.write(&silence[..part])?; + sink.write(&silence[..part])?; remaining -= part; } } @@ -400,7 +407,7 @@ async fn play_audio(mut consumer: moq_audio::decode::Consumer, playback: AudioPl if let Some(excess) = sink.buffered().checked_sub(depth) { tokio::time::sleep(excess).await; } - let _ = sink.write(part)?; + sink.write(part)?; } // Anchor the playout clock on where the speaker has actually reached, which @@ -410,9 +417,9 @@ async fn play_audio(mut consumer: moq_audio::decode::Consumer, playback: AudioPl let moved = presentation .lock() .unwrap() - .audio(timing.end, sink.buffered(), Instant::now()); + .audio(timing.end, sink.buffered(), Instant::now().into_std()); if moved { - let _ = proxy.send_event(Event::Wake); + output.send(Event::Wake); } } @@ -420,8 +427,8 @@ async fn play_audio(mut consumer: moq_audio::decode::Consumer, playback: AudioPl } /// Play out what a retired sink still holds, instead of cutting the tail off -/// by dropping it. -async fn drain(sink: Sink) { +/// by dropping it. `latency` is the depth the sink was opened with. +async fn drain(sink: impl Sink, latency: Duration) { let drain = async { // A partial period is left to the device: waiting on the last few // milliseconds costs a wakeup per iteration and can never fully settle. @@ -432,5 +439,134 @@ async fn drain(sink: Sink) { // A write tops the ring up to its latency and then adds a chunk, so that sum // is the deepest it can be when the track ends, and draining it takes exactly // that long in real time. - let _ = tokio::time::timeout(sink.input().latency + AUDIO_CHUNK + AUDIO_DRAIN_GRACE, drain).await; + let _ = tokio::time::timeout(latency + AUDIO_CHUNK + AUDIO_DRAIN_GRACE, drain).await; +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use hang::catalog::{AudioCodec, AudioConfig}; + use moq_mux::catalog::hang::Container; + + use super::*; + use crate::play::fake::Recorder; + + const SAMPLE_RATE: u32 = 48_000; + /// Samples per packet. + const PACKET: u64 = 960; + const PACKET_DURATION: Duration = Duration::from_millis(20); + + /// A mono PCM rendition, published and named in the catalog until dropped. + fn rendition( + broadcast: &moq_net::broadcast::Producer, + catalog: &catalog::Producer, + name: &str, + ) -> moq_mux::container::Producer { + let track = broadcast + .create_track(name, hang::container::track_info(hang::catalog::PRIORITY.audio)) + .unwrap(); + catalog + .audio( + track, + Container::Legacy(moq_mux::container::Kind::Audio), + AudioConfig::new(AudioCodec::Pcm, SAMPLE_RATE, 1), + ) + .unwrap() + } + + /// The `index`th packet of the broadcast, every sample set to `sample` so the + /// recorder can tell which rendition played it. + fn packet(index: u64, sample: f32) -> moq_mux::container::Frame { + let payload: Vec = std::iter::repeat_n(sample.to_le_bytes(), PACKET as usize) + .flatten() + .collect(); + moq_mux::container::Frame { + timestamp: moq_net::Timestamp::from_scale(index * PACKET, SAMPLE_RATE as u64).unwrap(), + duration: None, + payload: Bytes::from(payload), + keyframe: true, + } + } + + fn media(origin: &moq_net::origin::Producer, delay: Duration, output: Recorder) -> Media { + Media { + origin: origin.consume(), + broadcast: "room".to_string(), + args: Args { + catalog_format: None, + delay: delay.into(), + select: Default::default(), + }, + video: Default::default(), + presentation: Arc::new(Mutex::new(Presentation::new(delay))), + drained: Default::default(), + output, + } + } + + /// A publisher retires an audio rendition by naming its replacement and then + /// finishing the old track. The retired sink still holds a delay of audio, and + /// the replacement's sink holds its own before its first sample sounds, so + /// played one after the other the switch costs a delay of silence (#3966). + #[tokio::test] + async fn an_audio_rendition_switch_leaves_no_gap() { + tokio::time::pause(); + + const OLD: f32 = 0.25; + const NEW: f32 = 0.5; + let delay = Duration::from_millis(500); + + let origin = moq_tokio::origin::spawn(); + let mut broadcast = origin.create_broadcast("room").unwrap(); + broadcast.announce(Default::default()).unwrap(); + let mut catalog = catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); + + let recorder = Recorder::default(); + let player = tokio::spawn(media(&origin, delay, recorder.clone()).run()); + + // A second of the old rendition, published in real time. + // Paced against absolute deadlines: tokio rounds each sleep up to the next + // millisecond, which relative sleeps would accumulate into a publisher + // falling behind the speaker. + let mut old = rendition(&broadcast, &catalog, "old"); + let start = Instant::now(); + let mut index = 0; + while index < 50 { + old.write(packet(index, OLD)).unwrap(); + index += 1; + tokio::time::sleep_until(start + PACKET_DURATION * index as u32).await; + } + + // The replacement joins the catalog, then the old track finishes. + let mut new = rendition(&broadcast, &catalog, "new"); + new.write(packet(index, NEW)).unwrap(); + index += 1; + old.finish().unwrap(); + drop(old); + + while index < 100 { + tokio::time::sleep_until(start + PACKET_DURATION * index as u32).await; + new.write(packet(index, NEW)).unwrap(); + index += 1; + } + new.finish().unwrap(); + drop(new); + catalog.finish().unwrap(); + + player.await.unwrap(); + match recorder.events().pop() { + Some(Event::Ended) => {} + Some(Event::Failed(err)) => panic!("playback failed: {err}"), + _ => panic!("playback never ended"), + } + + let played = recorder.played(); + let old_end = played.iter().filter(|p| p.sample == OLD).map(|p| p.to).max().unwrap(); + let new_start = played.iter().filter(|p| p.sample == NEW).map(|p| p.from).min().unwrap(); + // The tail plays out while the replacement fills, so the two meet. What is + // left is the partial period `drain` leaves to the device, which dropping + // the sink cuts. + let gap = new_start.saturating_duration_since(old_end); + assert!(gap < AUDIO_CHUNK, "the switch went silent for {gap:?}"); + } } diff --git a/rs/moq-cli/src/play/mod.rs b/rs/moq-cli/src/play/mod.rs index 182acf7efd..74ce9d499c 100644 --- a/rs/moq-cli/src/play/mod.rs +++ b/rs/moq-cli/src/play/mod.rs @@ -4,6 +4,11 @@ //! window, and the cpal speaker. Everything the player decides rather than //! draws (argument validation, rendition selection, media timing) compiles //! without them, so a default build typechecks it and runs its tests. +//! +//! The media tasks need the decoders, so they stay behind `play`, but they +//! reach the devices only through `output`: tests drive them against a +//! recorder (`fake`), and `just rs play` runs those on every PR that reaches +//! this crate. // With `play` off the event loop is gone and nothing calls the modules below. // They are still compiled and tested, which is the point. @@ -15,9 +20,13 @@ mod playback; mod source; mod timeline; +#[cfg(all(test, feature = "play"))] +mod fake; #[cfg(feature = "play")] mod media; #[cfg(feature = "play")] +mod output; +#[cfg(feature = "play")] mod window; #[cfg(feature = "play")] diff --git a/rs/moq-cli/src/play/output.rs b/rs/moq-cli/src/play/output.rs new file mode 100644 index 0000000000..2127978d4c --- /dev/null +++ b/rs/moq-cli/src/play/output.rs @@ -0,0 +1,59 @@ +//! Where the media task's output goes: the window and the speaker in `moq play`, +//! a recorder in tests. +//! +//! This seam is what lets the task logic in `media.rs` run without a device. + +use std::time::Duration; + +use moq_audio::playback::Input; + +use super::window::Event; + +/// The window the media task wakes, and the speaker it opens. +pub(super) trait Output: Clone + Send + Sync + 'static { + type Speaker: Speaker; + + /// Open the speaker. Every sink opened from one mixes on the same device stream. + fn speaker(&self) -> impl Future> + Send; + + /// Tell the window something changed. Dropped if the window is gone. + fn send(&self, event: Event); +} + +/// An open speaker, closed once it and every sink from it are dropped. +pub(super) trait Speaker: Clone + Send + Sync + 'static { + type Sink: Sink; + + /// Start a stream of PCM that holds `input.latency` ahead of the speaker. + fn sink(&self, input: Input) -> anyhow::Result; +} + +/// One stream of PCM on its way to the speaker, cut off when dropped. +pub(super) trait Sink: Send + Sync + 'static { + /// Queue samples behind what is already buffered. + fn write(&mut self, samples: &[u8]) -> anyhow::Result<()>; + + /// How much audio is queued ahead of the speaker. + fn buffered(&self) -> Duration; +} + +impl Speaker for moq_audio::playback::Engine { + type Sink = moq_audio::playback::Sink; + + fn sink(&self, input: Input) -> anyhow::Result { + Ok(moq_audio::playback::Engine::sink(self, input)?) + } +} + +impl Sink for moq_audio::playback::Sink { + fn write(&mut self, samples: &[u8]) -> anyhow::Result<()> { + // Playback drops stay on the live timeline; retrying them would add + // latency, and the sink already reports them in its logs. + let _ = moq_audio::playback::Sink::write(self, samples)?; + Ok(()) + } + + fn buffered(&self) -> Duration { + moq_audio::playback::Sink::buffered(self) + } +} diff --git a/rs/moq-cli/src/play/window.rs b/rs/moq-cli/src/play/window.rs index 89e8ed8ba9..11ef44bbe3 100644 --- a/rs/moq-cli/src/play/window.rs +++ b/rs/moq-cli/src/play/window.rs @@ -18,6 +18,7 @@ use winit::window::{Window, WindowId}; use super::args::Args; use super::layout::fit; use super::media::Media; +use super::output::Output; use super::timeline::Presentation; /// How early a frame may be shown rather than waiting another wakeup for it. @@ -70,7 +71,7 @@ pub fn run( video: video.clone(), presentation: presentation.clone(), drained: drained.clone(), - proxy: proxy.clone(), + output: proxy.clone(), } .run(), ); @@ -102,6 +103,18 @@ pub fn run( } } +impl Output for EventLoopProxy { + type Speaker = moq_audio::playback::Engine; + + async fn speaker(&self) -> anyhow::Result { + Ok(moq_audio::playback::Engine::open(Default::default()).await?) + } + + fn send(&self, event: Event) { + let _ = self.send_event(event); + } +} + async fn watch_network(mut tasks: tokio::task::JoinSet>, proxy: EventLoopProxy) { while let Some(result) = tasks.join_next().await { let event = match result { diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 5fa8036fc1..306480c9ae 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -255,6 +255,9 @@ impl Publish { /// `broadcast`. Announce the broadcast afterwards: this constructor creates /// the catalog tracks, so announcing after it lands the advertisement with /// the tracks already in place. + /// + /// Stdin is a live feed with its own zero, so the container importers translate its + /// timestamps onto the broadcast clock the catalog advertises (`live`). pub fn new( mut broadcast: moq_net::broadcast::Producer, format: &PublishFormat, @@ -267,7 +270,7 @@ impl Publish { if let PublishFormat::Ts = format { let config = config.with_catalog(moq_mux::catalog::hang::Catalog::::default()); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config)?; - let ts = ts::Import::new(broadcast.clone(), catalog.reserve()); + let ts = ts::Import::new(broadcast.clone(), catalog.reserve()).live(); return Ok(Self { source: Source::Stream(PublishDecoder::Ts(Box::new(ts))), broadcast, @@ -286,12 +289,12 @@ impl Publish { }) } PublishFormat::Fmp4 => { - let fmp4 = fmp4::Import::new(broadcast.clone(), catalog.reserve()); + let fmp4 = fmp4::Import::new(broadcast.clone(), catalog.reserve()).live(); Source::Stream(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()); + let flv = flv::Import::new(broadcast.clone(), catalog.reserve()).live(); Source::Stream(PublishDecoder::Flv(Box::new(flv))) } }; @@ -757,6 +760,54 @@ mod tests { ); } + /// `moq import ts` publishes on the broadcast clock it advertises: a feed arriving a minute + /// after the broadcast began is live on arrival, not stamped with its own PTS (1.4s into bbb). + #[tokio::test] + async fn ts_import_publishes_on_the_broadcast_clock() { + let ago = Duration::from_secs(60); + let clock = moq_mux::Clock::at(std::time::Instant::now() - ago, std::time::SystemTime::now() - ago).unwrap(); + let broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + 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 { + panic!("expected a stream source"); + }; + let before = clock.now(); + decoder.decode_chunk(BBB).unwrap(); + let after = clock.now(); + decoder.finish().unwrap(); + + let catalog = hang::catalog::Catalog::<()>::subscribe(&consumer) + .await + .unwrap() + .next() + .await + .unwrap() + .expect("a catalog"); + assert_eq!( + catalog.clock, + Some(clock.wall()), + "the advertised clock is the one stamped on" + ); + let (name, config) = catalog.video.renditions.iter().next().expect("a video rendition"); + let track = consumer.track(name).unwrap().subscribe(None).await.unwrap(); + let container = moq_mux::catalog::hang::Container::try_from(config).unwrap(); + let first = Consumer::new(track, container) + .read() + .await + .unwrap() + .expect("a video frame") + .timestamp; + // The PES that anchors the mapping need not be this frame: the mux spaces them apart. + let skew = Duration::from_secs(2).as_micros(); + assert!( + before.as_micros() - skew <= first.as_micros() && first.as_micros() <= after.as_micros() + skew, + "the first frame is live on arrival: {first:?} not in {before:?}..={after:?}" + ); + } + /// 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 258ecfb1ff..676857bed3 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.5](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.4...moq-e2ee-v0.0.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + +## [0.0.4](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.3...moq-e2ee-v0.0.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.0.3](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.2...moq-e2ee-v0.0.3) - 2026-09-25 ### Other diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index 36f8d1e75c..fb37ee65ab 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.3" +version = "0.0.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index d03958a62e..ca2afe7700 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.5](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.4...moq-ffi-v0.4.5) - 2026-09-25 + +### Added + +- *(ffi)* advertise JSON tracks in the catalog, add binary data tracks ([#4137](https://github.com/moq-dev/moq/pull/4137)) + +### Fixed + +- *(ffi)* name the binary config conversion so moq-ffi compiles ([#4157](https://github.com/moq-dev/moq/pull/4157)) + +## [0.4.4](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.3...moq-ffi-v0.4.4) - 2026-09-25 + +### Added + +- *(ffi)* name the track an encoded audio or video publish writes ([#4097](https://github.com/moq-dev/moq/pull/4097)) +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.4.3](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.2...moq-ffi-v0.4.3) - 2026-09-25 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 141409f0b3..6e3d76ae70 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.3" +version = "0.4.5" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-ffi/src/binary.rs b/rs/moq-ffi/src/binary.rs new file mode 100644 index 0000000000..3c231757f7 --- /dev/null +++ b/rs/moq-ffi/src/binary.rs @@ -0,0 +1,129 @@ +//! Binary data tracks over the FFI boundary, advertised in the catalog. +//! +//! The binary counterpart of [`crate::json`]: opaque payloads (for example a camera's latest JPEG +//! thumbnail) on a named track, in either mode — `snapshot` (each payload supersedes the last) or +//! `stream` (every payload preserved in order). The broadcast's catalog carries +//! `binary.tracks.` (mode, plus `mime` and `compression` when set) for as long as the track +//! lives, so a consumer discovers it without knowing the application. + +use std::sync::Arc; + +use moq_mux::catalog::hang::Extra; + +use crate::error::MoqError; +use crate::producer::MoqBroadcastProducer; + +/// Options for a binary data track, in either mode (the mode is fixed by the constructor). +#[derive(Clone, uniffi::Record)] +pub struct MoqBinaryConfig { + /// DEFLATE-compress each payload, advertised in the catalog entry. + #[uniffi(default = false)] + pub compression: bool, + + /// The payloads' media type (e.g. `image/jpeg`), or `None` to leave it unstated. + #[uniffi(default = None)] + pub mime: Option, +} + +impl From for moq_mux::binary::Config { + fn from(config: MoqBinaryConfig) -> Self { + let mut out = moq_mux::binary::Config::default().with_compression(config.compression); + if let Some(mime) = config.mime { + out = out.with_mime(mime); + } + out + } +} + +#[uniffi::export] +impl MoqBroadcastProducer { + /// Publish a binary snapshot track (lossy latest-value) by name, advertised in the catalog. + /// + /// Errors if the catalog already carries an entry under `name`. + pub fn publish_binary_snapshot( + &self, + name: String, + config: MoqBinaryConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + self.with_state(|state| { + let track = state.broadcast.create_track(name, None)?; + let producer = state + .catalog + .binary_snapshot(track, moq_mux::binary::Config::from(config))?; + Ok(Arc::new(MoqBinarySnapshotProducer { + inner: std::sync::Mutex::new(Some(producer)), + })) + }) + } + + /// Publish a binary stream track (lossless append-log) by name, advertised in the catalog. + /// + /// Errors if the catalog already carries an entry under `name`. + pub fn publish_binary_stream( + &self, + name: String, + config: MoqBinaryConfig, + ) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + self.with_state(|state| { + let track = state.broadcast.create_track(name, None)?; + let producer = state + .catalog + .binary_stream(track, moq_mux::binary::Config::from(config))?; + Ok(Arc::new(MoqBinaryStreamProducer { + inner: std::sync::Mutex::new(Some(producer)), + })) + }) + } +} + +/// Publishes opaque payloads that consumers see as a single latest value. +#[derive(uniffi::Object)] +pub struct MoqBinarySnapshotProducer { + inner: std::sync::Mutex>>, +} + +#[uniffi::export] +impl MoqBinarySnapshotProducer { + /// Publish a new payload, superseding the last. + pub fn update(&self, payload: Vec) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let mut guard = self.inner.lock().unwrap(); + guard.as_mut().ok_or(MoqError::Closed)?.update(payload)?; + Ok(()) + } + + /// Finish the track and retire its catalog entry. + pub fn finish(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + producer.finish()?; + Ok(()) + } +} + +/// Publishes an ordered log of opaque payloads, one per append. +#[derive(uniffi::Object)] +pub struct MoqBinaryStreamProducer { + inner: std::sync::Mutex>>, +} + +#[uniffi::export] +impl MoqBinaryStreamProducer { + /// Append one payload to the log. + pub fn append(&self, payload: Vec) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let mut guard = self.inner.lock().unwrap(); + guard.as_mut().ok_or(MoqError::Closed)?.append(payload)?; + Ok(()) + } + + /// Finish the track and retire its catalog entry. + pub fn finish(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + producer.finish()?; + Ok(()) + } +} diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 243067d655..6b10c40e10 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -14,6 +14,7 @@ use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::Task; use crate::producer::MoqBroadcastProducer; +use moq_mux::catalog::hang::Extra; /// Options for a JSON snapshot track (lossy latest-value mode). /// @@ -101,10 +102,11 @@ mod tests { #[uniffi::export] impl MoqBroadcastProducer { - /// Publish a JSON snapshot track (lossy latest-value) by name. + /// Publish a JSON snapshot track (lossy latest-value) by name, advertised in the catalog. /// - /// Advertise it in the catalog yourself with - /// [`set_catalog_section`](Self::set_catalog_section) if consumers should discover it. + /// The broadcast's catalog carries `json.tracks.` (`mode: snapshot`, and + /// `compression: deflate` when set) for as long as the track lives; finishing or dropping the + /// producer retires it. Errors if the catalog already carries an entry under `name`. pub fn publish_json_snapshot( &self, name: String, @@ -112,16 +114,21 @@ impl MoqBroadcastProducer { ) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { - let broadcast = state.broadcast.clone(); - let track = broadcast.create_track(name, None)?; - let producer = moq_json::snapshot::Producer::::new(track, config.into()); + let track = state.broadcast.create_track(name, None)?; + let config = moq_mux::json::Config::default() + .with_compression(config.compression) + .with_delta_ratio(config.delta_ratio); + let producer = state.catalog.json_snapshot::(track, config)?; Ok(Arc::new(MoqJsonSnapshotProducer { inner: std::sync::Mutex::new(Some(producer)), })) }) } - /// Publish a JSON stream track (lossless append-log) by name. + /// Publish a JSON stream track (lossless append-log) by name, advertised in the catalog. + /// + /// The broadcast's catalog carries `json.tracks.` (`mode: stream`) for as long as the + /// track lives. Errors if the catalog already carries an entry under `name`. pub fn publish_json_stream( &self, name: String, @@ -129,9 +136,9 @@ impl MoqBroadcastProducer { ) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.with_state(|state| { - let broadcast = state.broadcast.clone(); - let track = broadcast.create_track(name, None)?; - let producer = moq_json::stream::Producer::::new(track, config.into()); + let track = state.broadcast.create_track(name, None)?; + let config = moq_mux::json::Config::default().with_compression(config.compression); + let producer = state.catalog.json_stream::(track, config)?; Ok(Arc::new(MoqJsonStreamProducer { inner: std::sync::Mutex::new(Some(producer)), })) @@ -175,7 +182,7 @@ impl MoqBroadcastConsumer { /// Publishes a JSON value that consumers see as a single latest state. #[derive(uniffi::Object)] pub struct MoqJsonSnapshotProducer { - inner: std::sync::Mutex>>, + inner: std::sync::Mutex>>, } #[uniffi::export] @@ -200,7 +207,7 @@ impl MoqJsonSnapshotProducer { /// Finish the track, closing any open group. pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let mut producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; producer.finish()?; Ok(()) } @@ -247,7 +254,7 @@ impl MoqJsonSnapshotConsumer { /// Publishes an ordered log of JSON records, one record per append. #[derive(uniffi::Object)] pub struct MoqJsonStreamProducer { - inner: std::sync::Mutex>>, + inner: std::sync::Mutex>>, } #[uniffi::export] @@ -271,7 +278,7 @@ impl MoqJsonStreamProducer { /// Finish the track, closing the group. pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let mut producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; + let producer = self.inner.lock().unwrap().take().ok_or(MoqError::Closed)?; producer.finish()?; Ok(()) } diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs index 1916f1282b..8b2743fdef 100644 --- a/rs/moq-ffi/src/lib.rs +++ b/rs/moq-ffi/src/lib.rs @@ -17,6 +17,7 @@ mod android; #[cfg(all(feature = "audio", not(target_arch = "wasm32")))] pub mod audio; pub mod bandwidth; +pub mod binary; pub mod consumer; pub mod demand; pub mod error; diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 10ff9e6578..fa39dc0c42 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -2,6 +2,7 @@ use super::origin::*; use super::producer::*; use super::server::MoqServer; use super::session::{MoqClient, MoqSession}; +use crate::binary::MoqBinaryConfig; use crate::consumer::MoqBroadcastConsumer; use crate::consumer::MoqFetchGroupOptions; use crate::consumer::MoqSubscription; @@ -4509,3 +4510,125 @@ async fn shutdown_cancels_and_drops_cleanly() { drop(client_origin); drop(server_origin); } + +/// The broadcast's current catalog, read on the publish side. +fn published_catalog( + broadcast: &MoqBroadcastProducer, +) -> moq_mux::catalog::hang::Catalog { + broadcast.with_state(|state| Ok(state.catalog.snapshot())).unwrap() +} + +/// JSON tracks are advertised in the catalog (no `set_catalog_section` needed) and retired on finish. +#[tokio::test] +async fn json_tracks_are_advertised_in_the_catalog() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let snapshot = broadcast + .publish_json_snapshot( + "status".into(), + MoqJsonSnapshotConfig { + delta_ratio: 4, + compression: true, + }, + ) + .unwrap(); + let stream = broadcast + .publish_json_stream("events".into(), MoqJsonStreamConfig { compression: false }) + .unwrap(); + + let catalog = published_catalog(&broadcast); + let entry = catalog.json.tracks.get("status").expect("snapshot track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.compression, Some(hang::catalog::Compression::Deflate)); + let entry = catalog.json.tracks.get("events").expect("stream track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.compression, None); + + snapshot.finish().unwrap(); + let catalog = published_catalog(&broadcast); + assert!( + !catalog.json.tracks.contains_key("status"), + "finished track still advertised" + ); + assert!(catalog.json.tracks.contains_key("events")); + stream.finish().unwrap(); + assert!(published_catalog(&broadcast).json.tracks.is_empty()); +} + +/// Binary tracks carry their mode and (optional) media type in the catalog. +#[tokio::test] +async fn binary_tracks_are_advertised_in_the_catalog() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let thumb = broadcast + .publish_binary_snapshot( + "thumbnail".into(), + MoqBinaryConfig { + compression: false, + mime: Some("image/jpeg".into()), + }, + ) + .unwrap(); + let log = broadcast + .publish_binary_stream( + "log".into(), + MoqBinaryConfig { + compression: false, + mime: None, + }, + ) + .unwrap(); + thumb.update(vec![0xff, 0xd8, 0xff]).unwrap(); + log.append(vec![1, 2, 3]).unwrap(); + + let catalog = published_catalog(&broadcast); + let entry = catalog + .binary + .tracks + .get("thumbnail") + .expect("binary snapshot advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.mime.as_deref(), Some("image/jpeg")); + let entry = catalog.binary.tracks.get("log").expect("binary stream advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.mime, None); + + thumb.finish().unwrap(); + assert!(matches!(thumb.update(vec![0]), Err(MoqError::Closed))); + log.finish().unwrap(); + assert!(published_catalog(&broadcast).binary.tracks.is_empty()); +} + +/// A second data track under a name the catalog already carries is refused, leaving the first. +#[tokio::test] +async fn data_track_names_cannot_collide() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let first = broadcast + .publish_json_snapshot( + "state".into(), + MoqJsonSnapshotConfig { + delta_ratio: 0, + compression: false, + }, + ) + .unwrap(); + assert!( + broadcast + .publish_binary_stream( + "state".into(), + MoqBinaryConfig { + compression: false, + mime: None, + }, + ) + .is_err(), + "a duplicate data track name should fail" + ); + assert_eq!( + published_catalog(&broadcast) + .json + .tracks + .get("state") + .map(|e| e.mode.clone()), + Some(hang::catalog::Mode::Snapshot) + ); + first.finish().unwrap(); +} diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 2b4eb93f89..71a0d63210 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.5](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.4...moq-gst-v0.4.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux, moq-tokio, hang + +## [0.4.4](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.3...moq-gst-v0.4.4) - 2026-09-25 + +### Fixed + +- *(moq-gst)* wait for the sink's reconnect loop to end on stop ([#4074](https://github.com/moq-dev/moq/pull/4074)) + ## [0.4.3](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.2...moq-gst-v0.4.3) - 2026-09-25 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 327a77d7c2..2e81769e45 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.3" +version = "0.4.5" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index 9135696fae..e07f6c0178 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -10,7 +10,7 @@ //! pad lifecycle, then an object lock. No path takes the element control while holding a pad lifecycle. use std::sync::{LazyLock, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use bytes::Bytes; @@ -685,7 +685,9 @@ impl MoqSink { if lifecycle.media.is_failed() { return Ok(gst::FlowSuccess::Ok); } - let outcome = lifecycle.media.push_buffer(data, pts, duration, current_running_time); + let outcome = lifecycle + .media + .push_buffer(data, pts, duration, current_running_time, Instant::now()); let changes = match &outcome { Ok(PushOutcome::Failed(reason)) => Some(lifecycle.fail(reason.clone())), _ => None, @@ -772,7 +774,9 @@ impl MoqSink { return false; } let requested = lifecycle.requested().map(str::to_owned); - let mut options = ProducerOptions::new(&caps).with_container(lifecycle.container().into()); + let mut options = ProducerOptions::new(&caps) + .with_container(lifecycle.container().into()) + .with_encoder(lifecycle.encoder()); if let Some(track) = requested.as_deref() { options = options.with_track(track); } diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index cf73a04d36..23ecdb1a33 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -4,6 +4,8 @@ //! that pad's own streaming thread, so this type is touched from one thread and needs no generation //! tagging or cross-thread failure map. +use std::time::Instant; + use anyhow::{Context, Result, ensure}; use bytes::Bytes; @@ -30,23 +32,50 @@ enum PadState { /// Both payloads are large (a codec importer, a container producer), so each is boxed to keep the /// enum small. enum Sink { - /// `audio` decides grouping. `import::Track` draws no audio boundaries of its own (every - /// packet is independently decodable, so there is no keyframe to group on), leaving them to - /// whoever knows the latency target. A live sink wants each packet forwarded without waiting, - /// so it cuts per frame. Video groups at its own keyframes and needs nothing. - Media { - track: Box, - audio: bool, - }, + Media(Box), Text(Box), Opaque(moq_net::track::Producer), } +/// An audio or video pad, published through a codec importer. +struct Media { + track: import::Track, + /// Decides grouping. `import::Track` draws no audio boundaries of its own (every packet is + /// independently decodable, so there is no keyframe to group on), leaving them to whoever knows + /// the latency target. A live sink wants each packet forwarded without waiting, so it cuts per + /// frame. Video groups at its own keyframes and needs nothing. + audio: bool, + /// Records each frame's handoff against the wall clock, raising the catalog jitter by how + /// irregularly a local encoder delivers. Imports leave it off: their arrival times describe the + /// file or network, not the encoder. + encoder: bool, +} + +impl Media { + /// Publish one frame at `micros` on the media clock, handed over at `now`. + fn write(&mut self, data: &Bytes, micros: u64, now: Instant) -> Result<()> { + let ts = hang::container::Timestamp::from_micros(micros).ok(); + self.track.decode(data, ts)?; + // One group (one QUIC stream) per audio packet, so the relay forwards it without waiting for + // the next. + if self.audio { + self.track.cut(None)?; + } + if self.encoder { + // Skipping the observation would publish a frame the jitter never saw. + let ts = ts.context("encoder frame timestamp out of range")?; + self.track.flush(ts, now)?; + } + Ok(()) + } +} + /// Inputs used to build a producer after a pad observes caps. pub(super) struct ProducerOptions<'a> { container: hang::catalog::Container, caps: &'a gst::Caps, requested: Option<&'a str>, + encoder: bool, } impl<'a> ProducerOptions<'a> { @@ -55,6 +84,7 @@ impl<'a> ProducerOptions<'a> { container: hang::catalog::Container::default(), caps, requested: None, + encoder: false, } } @@ -67,6 +97,11 @@ impl<'a> ProducerOptions<'a> { self.requested = Some(track); self } + + pub(super) fn with_encoder(mut self, encoder: bool) -> Self { + self.encoder = encoder; + self + } } /// A subtitle pad. GStreamer hands us one decoded cue per buffer (`text/x-raw`, UTF-8) with the @@ -245,8 +280,15 @@ impl Pad { container, caps, requested, + encoder, } = options; let structure = caps.structure(0).context("empty caps")?; + // Only a codec rendition carries the jitter a local encoder's clock would raise. + ensure!( + !encoder || !matches!(structure.name().as_str(), "application/octet-stream" | "text/x-raw"), + "encoder is only supported on audio and video pads, not {}", + structure.name() + ); // Renegotiation: finalize the previous producer before replacing it (closed once, not abandoned). self.finalize()?; // Opaque data has no codec importer and no catalog entry, so it never reaches the codec match. @@ -422,10 +464,7 @@ impl Pad { } other => anyhow::bail!("unsupported caps: {other}"), }; - self.track = Some(Sink::Media { - track: Box::new(track), - audio, - }); + self.track = Some(Sink::Media(Box::new(Media { track, audio, encoder }))); self.caps = Some(caps.clone()); Ok(name) } @@ -597,8 +636,9 @@ impl Pad { /// Import one buffer into the producer. A failed or producer-less pad drops the buffer; a timeline /// drop is logged. Unstamped opaque data on an active timeline uses the element's current running - /// time. A bad bitstream (or an oversized frame, rejected by moq-net) invalidates only this pad and - /// says so in the returned outcome. Returns an error when an unstamped opaque buffer has no current + /// time. An encoder pad records `now` as the frame's handoff once it is published. A bad bitstream + /// (or an oversized frame, rejected by moq-net) invalidates only this pad and says so in the + /// returned outcome. Returns an error when an unstamped opaque buffer has no current /// running time, so the caller fails the flow instead of silently dropping data. pub fn push_buffer( &mut self, @@ -606,6 +646,7 @@ impl Pad { pts: Option, duration: Option, current_running_time: Option, + now: Instant, ) -> std::result::Result { if self.failed { return Ok(PushOutcome::Dropped); @@ -625,15 +666,7 @@ impl Pad { match timestamp { Ok(micros) => { let result: Result<()> = match self.track.as_mut().expect("track present") { - Sink::Media { track, audio } => { - let ts = hang::container::Timestamp::from_micros(micros).ok(); - track - .decode(&data, ts) - // One group (one QUIC stream) per audio packet, so the relay forwards - // it without waiting for the next. See `Sink::Media`. - .and_then(|()| if *audio { track.cut(None) } else { Ok(()) }) - .map_err(Into::into) - } + Sink::Media(media) => media.write(&data, micros, now), Sink::Text(text) => match std::str::from_utf8(&data) { // A cue with no duration would never be dismissed, so drop it rather than pin it // on screen; the demuxer supplies one for every real subtitle sample. @@ -687,7 +720,7 @@ impl Pad { return Ok(false); }; let closed = match track { - Sink::Media { mut track, .. } => track.finish().map_err(anyhow::Error::from), + Sink::Media(mut media) => media.track.finish().map_err(anyhow::Error::from), Sink::Text(mut text) => text.producer.finish().map_err(anyhow::Error::from), Sink::Opaque(producer) => producer.finish().map_err(anyhow::Error::from), }; @@ -831,8 +864,14 @@ mod tests { "the reserved name is the requested one" ); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let snapshot = catalog.snapshot(); let renditions: Vec = snapshot.video.renditions.keys().map(|name| name.to_string()).collect(); @@ -933,14 +972,26 @@ mod tests { pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), None)); // No observe_segment: the pad stays in NoSegment. assert_eq!( - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(), + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now() + ) + .unwrap(), PushOutcome::NoSegment, "first no-segment buffer is reported" ); assert_eq!( - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(), + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now() + ) + .unwrap(), PushOutcome::Dropped, "subsequent no-segment buffers are not re-reported" ); @@ -995,7 +1046,13 @@ mod tests { assert!(!data.is_failed()); video.observe_segment(time_segment()); video - .push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) + .push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) .unwrap(); let snapshot = catalog.snapshot(); @@ -1028,6 +1085,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(40)), None, None, + Instant::now(), ) .unwrap(); pad.push_buffer( @@ -1035,6 +1093,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(80)), None, None, + Instant::now(), ) .unwrap(); @@ -1086,8 +1145,14 @@ mod tests { CapsOutcome::Active("camera".to_string()) ); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let config = catalog.snapshot().video.renditions.get("camera").cloned().unwrap(); assert_eq!(config.container, hang::catalog::Container::Loc); @@ -1155,6 +1220,7 @@ mod tests { None, None, Some(gst::ClockTime::from_mseconds(25)), + Instant::now(), ) .unwrap(); assert!(!pad.is_failed(), "a missing PTS uses the supplied running time"); @@ -1186,7 +1252,7 @@ mod tests { pad.observe_segment(time_segment()); assert!( - pad.push_buffer(Bytes::from_static(b"no timestamp"), None, None, None) + pad.push_buffer(Bytes::from_static(b"no timestamp"), None, None, None, Instant::now()) .is_err(), "the caller gets a hard error instead of a silent drop" ); @@ -1205,8 +1271,14 @@ mod tests { ); assert!(pad.is_failed()); pad.observe_segment(time_segment()); - pad.push_buffer(Bytes::from_static(b"x"), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + Bytes::from_static(b"x"), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); } // A real IDR AU emits a frame to the published track (not just a rendition off the SPS). @@ -1217,8 +1289,14 @@ mod tests { let mut pad = Pad::new(); pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), None)); pad.observe_segment(time_segment()); - pad.push_buffer(h264_keyframe_au(), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); let snapshot = catalog.snapshot(); let track = snapshot.video.renditions.keys().next().expect("a video rendition"); @@ -1419,6 +1497,7 @@ mod tests { Some(gst::ClockTime::ZERO), Some(gst::ClockTime::from_seconds(1)), None, + Instant::now(), ) .unwrap(); assert!(pad.is_failed()); @@ -1445,6 +1524,7 @@ mod tests { Some(gst::ClockTime::from_mseconds(start_ms)), Some(gst::ClockTime::from_mseconds(dur_ms)), None, + Instant::now(), ) .unwrap(); } @@ -1463,8 +1543,14 @@ mod tests { let mut pad = Pad::new(); pad.observe_caps(&broadcast, &catalog, producer_options(&text_caps(), None)); pad.observe_segment(time_segment()); - pad.push_buffer(Bytes::from_static(b"hello"), Some(gst::ClockTime::ZERO), None, None) - .unwrap(); + pad.push_buffer( + Bytes::from_static(b"hello"), + Some(gst::ClockTime::ZERO), + None, + None, + Instant::now(), + ) + .unwrap(); assert!(!pad.is_failed(), "a durationless cue drops the buffer, not the pad"); } @@ -1503,4 +1589,64 @@ mod tests { .count(); assert_eq!(emitted, 5, "all five decode-order frames must emit (got {emitted})"); } + + // `multifilesrc ! parsebin` and a live encoder deliver identical segments and PTS, so the opt-in is + // the only thing that tells them apart. The same late frame raises only the encoder pad's jitter. + #[test] + fn only_an_encoder_pad_measures_its_handoff() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + let mut encoder = Pad::new(); + let mut import = Pad::new(); + encoder.observe_caps( + &broadcast, + &catalog, + producer_options(&h264_caps(), Some("encoder")).with_encoder(true), + ); + import.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), Some("import"))); + + // The third frame reaches the sink 100ms later than its running time says it should. + let anchor = Instant::now(); + for (pts, arrival) in [(0, 0), (33, 33), (66, 166)] { + for pad in [&mut encoder, &mut import] { + pad.observe_segment(time_segment()); + let outcome = pad + .push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + std::time::Duration::from_millis(arrival), + ) + .unwrap(); + assert_eq!(outcome, PushOutcome::Published); + } + } + + let snapshot = catalog.snapshot(); + let jitter = |name: &str| snapshot.video.renditions[name].jitter; + assert_eq!(jitter("encoder"), Some(std::time::Duration::from_millis(100))); + assert_eq!(jitter("import"), None, "an import's arrival never reaches the catalog"); + } + + // 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] + fn an_encoder_pad_must_carry_audio_or_video() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + for caps in [text_caps(), opaque_caps()] { + let mut pad = Pad::new(); + let outcome = pad.observe_caps( + &broadcast, + &catalog, + producer_options(&caps, Some("data")).with_encoder(true), + ); + assert!( + matches!(outcome, CapsOutcome::Failed(ref reason) if reason.starts_with("encoder is only supported")), + "{outcome:?}" + ); + assert!(pad.is_failed()); + } + } } diff --git a/rs/moq-gst/src/sink/request_pad.rs b/rs/moq-gst/src/sink/request_pad.rs index d8f8f540c4..9bc31f19ab 100644 --- a/rs/moq-gst/src/sink/request_pad.rs +++ b/rs/moq-gst/src/sink/request_pad.rs @@ -41,6 +41,8 @@ struct Settings { effective: Option, /// The wire container selected for this pad's media producer. container: MediaContainer, + /// Whether a local encoder feeds this pad, so its handoff clock may raise the catalog jitter. + encoder: bool, /// What the track is doing, read back through `status`. status: Status, /// The reason the pad was invalidated, read back through `track-error`. @@ -104,6 +106,11 @@ impl PadLifecycle { self.settings.container } + /// Whether a local encoder feeds this pad. + pub(super) fn encoder(&self) -> bool { + self.settings.encoder + } + /// Record the name reserved by a successful CAPS event. pub(super) fn commit(&mut self, track: String) -> Notifications { let before = self @@ -186,6 +193,19 @@ impl ObjectImpl for MoqSinkPadImp { .default_value(MediaContainer::Legacy) .mutable_playing() .build(), + // Provenance is not in the caps: `multifilesrc ! parsebin` hands over the same TIME + // segment and PTS a live encoder does, so only the application can say which it is. + glib::ParamSpecBoolean::builder("encoder") + .nick("Local encoder") + .blurb( + "A local encoder feeds this pad, so the catalog jitter includes how late each \ + frame reaches the sink behind its running time. Leave it off for file, demuxed, \ + and network media, whose arrival says nothing about the original encoder. Audio \ + and video only. Writable in any state until the CAPS event reserves the track", + ) + .default_value(false) + .mutable_playing() + .build(), glib::ParamSpecEnum::builder::("track-status") .nick("Status") .blurb( @@ -219,7 +239,7 @@ impl ObjectImpl for MoqSinkPadImp { ); return; } - // A producer keeps its reserved name and wire container for its whole life, so a later write + // A producer keeps its reserved name, wire container, and provenance for its whole life, so a later write // would read back without ever reaching the broadcast or the catalog. if lifecycle.settings.effective.is_some() { gst::warning!( @@ -236,6 +256,7 @@ impl ObjectImpl for MoqSinkPadImp { lifecycle.settings.requested = value.get::>().unwrap().filter(|name| !name.is_empty()) } "container" => lifecycle.settings.container = value.get().unwrap(), + "encoder" => lifecycle.settings.encoder = value.get().unwrap(), _ => unreachable!(), } } @@ -250,6 +271,7 @@ impl ObjectImpl for MoqSinkPadImp { .or_else(|| lifecycle.settings.requested.clone()) .to_value(), "container" => lifecycle.settings.container.to_value(), + "encoder" => lifecycle.settings.encoder.to_value(), "track-status" => lifecycle.settings.status.to_value(), "track-error" => lifecycle.settings.error.clone().to_value(), _ => unreachable!(), diff --git a/rs/moq-gst/tests/element.rs b/rs/moq-gst/tests/element.rs index 114b6166ad..833590c89a 100644 --- a/rs/moq-gst/tests/element.rs +++ b/rs/moq-gst/tests/element.rs @@ -572,6 +572,20 @@ fn a_pipeline_description_selects_loc() { ); } +#[test] +fn a_pipeline_description_marks_an_encoder_pad() { + init(); + let sink = gst::parse::launch("moqsink name=publisher url=https://127.0.0.1:1 broadcast=test sink_0::encoder=true") + .expect("parse the description"); + let _pad = sink.request_pad_simple("sink_0").expect("request sink_0"); + assert!(child_of(&sink, "sink_0").property::("encoder")); + let _other = sink.request_pad_simple("sink_1").expect("request sink_1"); + assert!( + !child_of(&sink, "sink_1").property::("encoder"), + "a pad is an import unless it says otherwise" + ); +} + // The acceptance criterion: once CAPS reserves the track, its name and container are fixed; stopping // the element makes both configurable again. #[test] diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index 78f8db5af7..c4767cc0d5 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/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-hls-v0.5.4...moq-hls-v0.5.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux, hang + +## [0.5.4](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.3...moq-hls-v0.5.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.5.3](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.2...moq-hls-v0.5.3) - 2026-09-25 ### Added diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index e759559a79..3a95a6abef 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.3" +version = "0.5.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index b4a1ac63a2..8e6ac917dc 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.2](https://github.com/moq-dev/moq/compare/moq-json-v0.5.1...moq-json-v0.5.2) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + +## [0.5.1](https://github.com/moq-dev/moq/compare/moq-json-v0.5.0...moq-json-v0.5.1) - 2026-09-25 + +### Other + +- *(json)* skip unchanged root entries in the snapshot diff ([#4020](https://github.com/moq-dev/moq/pull/4020)) + ## [0.5.0](https://github.com/moq-dev/moq/compare/moq-json-v0.4.2...moq-json-v0.5.0) - 2026-09-25 ### Fixed diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 1b1307a120..847ae81c4a 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.0" +version = "0.5.2" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index 29fcae56c0..c7cd3ed095 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.13](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.12...moq-loc-v0.2.13) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + +## [0.2.12](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.11...moq-loc-v0.2.12) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.2.11](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.10...moq-loc-v0.2.11) - 2026-09-25 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index 4d9a79adfe..1c07c79750 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.11" +version = "0.2.13" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 533b50c673..1d5b8a25c4 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.5](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.4...moq-mux-v0.10.5) - 2026-09-25 + +### Added + +- *(ffi)* advertise JSON tracks in the catalog, add binary data tracks ([#4137](https://github.com/moq-dev/moq/pull/4137)) + +### Fixed + +- *(audio)* honor and validate Opus stream descriptions ([#4130](https://github.com/moq-dev/moq/pull/4130)) + +### Other + +- *(capture)* drive native capture through clock edge cases in CI ([#4125](https://github.com/moq-dev/moq/pull/4125)) + +## [0.10.4](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.3...moq-mux-v0.10.4) - 2026-09-25 + +### Added + +- *(libmoq)* advertise JSON tracks in the catalog, add binary data tracks ([#4073](https://github.com/moq-dev/moq/pull/4073)) +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + +### Fixed + +- *(moq-mux)* order TS export by media time, not arrival ([#4001](https://github.com/moq-dev/moq/pull/4001)) + ## [0.10.3](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.2...moq-mux-v0.10.3) - 2026-09-25 ### Fixed diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 5ff45656de..88d5e3da40 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.3" +version = "0.10.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/src/binary.rs b/rs/moq-mux/src/binary.rs index 4f5ab5f4dc..d0ad13a996 100644 --- a/rs/moq-mux/src/binary.rs +++ b/rs/moq-mux/src/binary.rs @@ -47,17 +47,20 @@ //! # } //! ``` +use std::marker::PhantomData; + use bytes::Bytes; use hang::catalog::{BinaryConfig, Compression, Mode}; -use crate::catalog::Rendition; use crate::catalog::hang::CatalogExt; +use crate::catalog::{IntoRendition, Listing, RenditionConfig}; /// Everything a binary track declares about itself, beyond its mode and name. /// /// Start from [`default`](Default::default) and chain the setters. The mode is not in here: it is -/// fixed by which producer you create. +/// fixed by which producer you create. To list the track in an application's own catalog section +/// instead of `binary`, pass that section's entry (see [`IntoRendition`]). #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct Config { @@ -83,16 +86,33 @@ impl Config { self.mime = Some(mime.into()); self } +} - /// The catalog entry describing a track published under this config in `mode`. - pub(crate) fn entry(&self, mode: Mode) -> BinaryConfig { - let mut entry = BinaryConfig::new(mode); +impl IntoRendition for Config { + type Config = BinaryConfig; + + fn into_rendition(self) -> BinaryConfig { + // The producer overwrites the mode with the one it publishes in. + let mut entry = BinaryConfig::new(Mode::Snapshot); entry.compression = self.compression.then_some(Compression::Deflate); - entry.mime = self.mime.clone(); + entry.mime = self.mime; entry } } +/// Fix `config`'s mode and return whether its frames are compressed. +/// +/// Errors on a compression this build can't write, rather than advertising one the frames don't use, +/// and on a `broadcast` reference, which would point consumers away from the track this publishes. +fn prepare(config: &mut impl AsMut, mode: Mode) -> crate::Result { + let binary = config.as_mut(); + if binary.broadcast.is_some() { + return Err(crate::Error::ForeignBroadcast); + } + binary.mode = mode; + crate::compression(binary.compression.as_ref()) +} + /// Publishes a latest-value binary track, advertised in the catalog for as long as this handle /// lives. /// @@ -100,27 +120,33 @@ impl Config { /// For a log where every payload survives, use [`Stream`]. pub struct Snapshot { inner: moq_binary::snapshot::Producer, - rendition: Rendition, + listing: Listing, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Snapshot { - pub(crate) fn new( + pub(crate) fn new + AsMut>( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, + rendition: crate::catalog::Rendition, + mut config: C, ) -> crate::Result { let mut binary = moq_binary::snapshot::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Snapshot)? { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::snapshot::Producer::new(track, binary); - rendition.set(config.entry(Mode::Snapshot))?; - Ok(Self { inner, rendition }) + let listing = Listing::new(rendition, config)?; + Ok(Self { + inner, + listing, + _catalog: PhantomData, + }) } /// The track name, which is also the catalog key. pub fn name(&self) -> &str { - self.rendition.name() + self.listing.name() } /// Create a subscriber for the underlying track. @@ -130,7 +156,10 @@ impl Snapshot { /// Publish a new payload, superseding the previous one. pub fn update(&mut self, payload: impl Into) -> crate::Result<()> { - Ok(self.inner.update(payload)?) + let payload = payload.into(); + let len = payload.len(); + self.inner.update(payload)?; + self.listing.record(|| len) } /// Finish the track and retire its catalog entry. @@ -154,25 +183,28 @@ pub struct Stream { /// Cleared when a terminal failure ends the track, which retires the catalog entry with it. An /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. - rendition: Option>, + listing: Option, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Stream { - pub(crate) fn new( + pub(crate) fn new + AsMut>( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, + rendition: crate::catalog::Rendition, + mut config: C, ) -> crate::Result { let mut binary = moq_binary::stream::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Stream)? { binary.compression = moq_binary::Compression::Deflate; } let inner = moq_binary::stream::Producer::new(track, binary); - rendition.set(config.entry(Mode::Stream))?; + let listing = Listing::new(rendition, config)?; Ok(Self { inner, - name: rendition.name().to_string(), - rendition: Some(rendition), + name: listing.name().to_string(), + listing: Some(listing), + _catalog: PhantomData, }) } @@ -192,18 +224,25 @@ impl Stream { /// Append one payload to the log. /// /// A payload that cannot be written ends the track (see - /// [`moq_binary::stream::Producer::append`]) and retires the catalog entry with it. + /// [`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 Err(err) = self.inner.append(payload) else { - return Ok(()); - }; - - // The inner producer has already closed the track. Dropping the rendition 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.rendition = None; + 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()); + } - Err(err.into()) + match &mut self.listing { + Some(listing) => listing.record(|| len), + None => Ok(()), + } } /// Finish the track and retire its catalog entry. @@ -425,4 +464,198 @@ mod test { assert!(broadcast.create_track("data", None).is_err()); } + + /// A data track listed in an application's own section, beside its own per-track fields. + mod section { + use std::collections::BTreeMap; + + use serde::{Deserialize, Serialize}; + + use super::*; + use crate::catalog::Estimate; + use crate::catalog::hang::Catalog; + + #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] + struct Ext { + #[serde(rename = "com.example.mavlink", default, skip_serializing_if = "BTreeMap::is_empty")] + mavlink: BTreeMap, + } + + impl CatalogExt for Ext {} + + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] + struct Mavlink { + #[serde(flatten)] + binary: BinaryConfig, + sysid: u8, + } + + impl AsMut for Mavlink { + fn as_mut(&mut self) -> &mut BinaryConfig { + &mut self.binary + } + } + + impl RenditionConfig for Mavlink { + fn insert(self, catalog: &mut Catalog, name: &str) { + catalog.ext.mavlink.insert(name.to_string(), self); + } + fn get_mut<'a>(catalog: &'a mut Catalog, name: &str) -> Option<&'a mut Self> { + catalog.ext.mavlink.get_mut(name) + } + fn remove(catalog: &mut Catalog, name: &str) { + catalog.ext.mavlink.remove(name); + } + + fn detects() -> bool { + true + } + fn estimate(&self) -> Estimate { + Estimate::default() + .with_bitrate(self.binary.bitrate) + .with_jitter(self.binary.jitter) + } + fn set_estimate(&mut self, estimate: Estimate) { + self.binary.bitrate = estimate.bitrate; + self.binary.jitter = estimate.jitter; + } + } + + fn mavlink(sysid: u8) -> Mavlink { + let mut binary = BinaryConfig::new(Mode::Snapshot); + binary.compression = Some(Compression::Deflate); + Mavlink { binary, sysid } + } + + fn catalog() -> (moq_net::broadcast::Producer, crate::catalog::Producer) { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let config = crate::catalog::Config::default().with_catalog(Catalog::::default()); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + (broadcast, catalog) + } + + /// The producer fixes the mode and keeps the application's fields; a `Catalog` consumer + /// reads the entry back and subscribes through its embedded config alone. + #[tokio::test] + async fn roundtrips_through_a_catalog_consumer() { + let (mut broadcast, catalog) = catalog(); + let source = crate::source::announced(&broadcast.consume()); + let mut consumer = catalog.consume().unwrap(); + + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), mavlink(7)) + .unwrap(); + telemetry.append(&b"heartbeat"[..]).unwrap(); + + let published = consumer.next().await.unwrap().expect("catalog published"); + let entry = published.ext.mavlink.get("telemetry").expect("missing entry"); + assert_eq!(entry.sysid, 7, "the application's fields survive"); + assert_eq!(entry.binary.mode, Mode::Stream, "the producer fixes the mode"); + assert_eq!(entry.binary.compression, Some(Compression::Deflate)); + assert!(published.binary.tracks.is_empty(), "not listed in the binary section"); + + let mut reader = crate::catalog::Entry::new("telemetry", &entry.binary) + .subscribe(&source) + .await + .unwrap(); + telemetry.finish().unwrap(); + assert_eq!(reader.next().await.unwrap(), Some(Bytes::from_static(b"heartbeat"))); + assert_eq!(reader.next().await.unwrap(), None); + } + + #[test] + fn dropping_the_producer_retires_the_entry() { + let (mut broadcast, catalog) = catalog(); + let telemetry = catalog + .binary_snapshot(track(&mut broadcast, "telemetry"), mavlink(1)) + .unwrap(); + assert!(catalog.snapshot().ext.mavlink.contains_key("telemetry")); + + drop(telemetry); + assert!(!catalog.snapshot().ext.mavlink.contains_key("telemetry")); + } + + /// The name is owned per section, so an entry already in the application's section refuses + /// a second producer without touching the first. + #[test] + fn a_duplicate_name_is_refused() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let mut seed = Catalog::::default(); + seed.ext.mavlink.insert("telemetry".to_string(), mavlink(1)); + let config = crate::catalog::Config::default().with_catalog(seed); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + + assert!(matches!( + catalog.binary_stream(track(&mut broadcast, "telemetry"), mavlink(2)), + Err(crate::Error::Hang(hang::Error::Duplicate(_))) + )); + assert_eq!(catalog.snapshot().ext.mavlink["telemetry"].sysid, 1); + } + + /// A compression this build can't write is refused rather than advertised over plain frames. + #[test] + fn an_unknown_compression_is_refused() { + let (mut broadcast, catalog) = catalog(); + let mut entry = mavlink(1); + entry.binary.compression = Some(Compression::Unknown("zstd".to_string())); + + assert!(matches!( + catalog.binary_stream(track(&mut broadcast, "telemetry"), entry), + Err(crate::Error::UnsupportedCompression(_)) + )); + assert!(catalog.snapshot().ext.mavlink.is_empty()); + } + + /// The producer publishes locally, so an entry pointing at another broadcast is refused rather + /// than advertised: consumers would resolve it there and never reach the published payloads. + #[test] + fn a_broadcast_reference_is_refused() { + let (mut broadcast, catalog) = catalog(); + let mut entry = mavlink(1); + entry.binary.broadcast = Some(moq_net::path::RelativeOwned::new("source")); + + assert!(matches!( + catalog.binary_stream(track(&mut broadcast, "telemetry"), entry), + Err(crate::Error::ForeignBroadcast) + )); + assert!(catalog.snapshot().ext.mavlink.is_empty()); + } + + /// Writes fill an absent bitrate, through the entry's embedded config. + #[test] + fn detects_bitrate() { + let (mut broadcast, catalog) = catalog(); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), mavlink(1)) + .unwrap(); + + // 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(); + } + + let entry = &catalog.snapshot().ext.mavlink["telemetry"]; + assert_eq!(entry.binary.bitrate, Some(1_000_000)); + 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. + #[test] + fn a_supplied_bitrate_skips_measurement() { + let (mut broadcast, catalog) = catalog(); + let mut entry = mavlink(1); + entry.binary.bitrate = Some(64_000); + let mut telemetry = catalog + .binary_stream(track(&mut broadcast, "telemetry"), entry) + .unwrap(); + + let listing = telemetry.listing.as_mut().unwrap(); + listing + .record(|| panic!("measured a write despite a supplied bitrate")) + .unwrap(); + 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 new file mode 100644 index 0000000000..10f73b0a0b --- /dev/null +++ b/rs/moq-mux/src/catalog/data.rs @@ -0,0 +1,128 @@ +use super::hang::CatalogExt; +use super::{Estimator, Rendition, RenditionConfig}; + +/// The config a data producer ([`Producer::json_stream`](super::Producer::json_stream) and the +/// like) publishes as its catalog entry. +/// +/// Implemented for the [`json::Config`](crate::json::Config) and +/// [`binary::Config`](crate::binary::Config) builders, which list the track in the `json` or +/// `binary` section. Also implemented for any [`RenditionConfig`] that embeds the data config `D` +/// ([`JsonConfig`](hang::catalog::JsonConfig) or [`BinaryConfig`](hang::catalog::BinaryConfig)) +/// through [`AsMut`], which is how an application lists a data track in its own section beside its +/// own per-track fields; see the [`RenditionConfig`] example. +/// +/// The producer sets the embedded config's `mode`, encodes the track with its `compression`, and +/// owns the entry: it is written when the producer is created and removed when it drops. +pub trait IntoRendition { + /// The catalog entry, embedding `D`. + type Config: RenditionConfig + AsMut; + + /// Build the catalog entry. + fn into_rendition(self) -> Self::Config; +} + +impl + AsMut> IntoRendition for C { + type Config = C; + + fn into_rendition(self) -> C { + self + } +} + +/// A data track's catalog entry, owned for the life of its producer and kept current with the +/// bitrate 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, + 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. +trait Owned: Send + Sync { + fn name(&self) -> &str; + fn timestamp(&self) -> crate::Result; + fn estimate(&mut self, estimate: super::Estimate) -> crate::Result<()>; +} + +impl> Owned for Rendition { + fn name(&self) -> &str { + Rendition::name(self) + } + fn timestamp(&self) -> crate::Result { + Rendition::timestamp(self, None) + } + fn estimate(&mut self, estimate: super::Estimate) -> crate::Result<()> { + Rendition::estimate(self, estimate) + } +} + +impl Listing { + /// Publish `config` as the entry `rendition` reserved. + pub(crate) fn new>( + mut rendition: Rendition, + config: C, + ) -> crate::Result { + let measures = C::detects() && config.estimate().bitrate.is_none(); + rendition.set(config)?; + Ok(Self { + rendition: Box::new(rendition), + estimator: Estimator::new(), + measures, + }) + } + + /// The track name, which is also the catalog key. + pub(crate) fn name(&self) -> &str { + 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(()); + } + let now = self.rendition.timestamp()?; + self.record_at(now, bytes()) + } + + /// [`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<()> { + // 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(()) + } + } + + 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 c18738f8b2..88ef14f672 100644 --- a/rs/moq-mux/src/catalog/mod.rs +++ b/rs/moq-mux/src/catalog/mod.rs @@ -25,6 +25,7 @@ pub mod msf; mod claim; mod consumer; +mod data; mod entry; mod estimate; mod format; @@ -35,6 +36,8 @@ 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 use entry::Entry; pub use estimate::{Estimate, Estimator}; pub use format::*; diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 6df191bfff..2d49b61510 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -615,7 +615,8 @@ mod test { head.push(6); // channel_count (5.1) head.extend_from_slice(&0u16.to_le_bytes()); // pre_skip head.extend_from_slice(&24_000u32.to_le_bytes()); // sample_rate - head.extend_from_slice(&[0, 0, 0]); // output gain (i16) + channel mapping family (1 byte) + head.extend_from_slice(&[0, 0, 1]); // output gain (i16) + channel mapping family 1 + head.extend_from_slice(&[4, 2, 0, 4, 1, 2, 3, 5]); // streams, coupled, Vorbis 5.1 mapping let init_b64 = base64::engine::general_purpose::STANDARD.encode(&head); let mut track = audio_track("audio0", moq_msf::Packaging::Loc); @@ -631,6 +632,23 @@ mod test { assert_eq!(audio.channel_count, 6); } + #[test] + fn opus_head_with_trailing_bytes_is_error() { + let mut head = crate::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec(); + head.push(0); + let init_b64 = base64::engine::general_purpose::STANDARD.encode(&head); + + let mut track = audio_track("audio0", moq_msf::Packaging::Loc); + track.codec = Some("opus".to_string()); + track.samplerate = None; + track.channel_config = None; + track.init_data = Some(init_b64); + let msf = moq_msf::Catalog::new(vec![track]); + + let err = from_msf::<()>(&msf).expect_err("trailing bytes should error"); + assert!(err.to_string().contains("trailing bytes"), "unexpected error: {err}"); + } + #[test] fn audio_missing_samplerate_and_channels_derived_from_aac_config() { // Legacy + AAC → parse AudioSpecificConfig for samplerate and channels. diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 9931f815d5..0d0958a123 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -653,20 +653,28 @@ impl Producer { /// Publish `track` as a latest-value JSON track, advertising it in the catalog. /// /// The caller creates the track on the broadcast, as it does for a media track; this writes its - /// catalog entry and removes the - /// entry when the returned handle drops. The catalog key is [`track.name()`](moq_net::track::Producer::name) - /// verbatim, with no `.z` suffix even when compressed, since the entry's compression flag is - /// what a consumer reads. + /// catalog entry and removes the entry when the returned handle drops. The catalog key is + /// [`track.name()`](moq_net::track::Producer::name) verbatim, with no `.z` suffix even when + /// compressed, since the entry's compression flag is what a consumer reads. /// - /// Errors if the catalog already carries an entry under that name, for example one seeded - /// through [`Config::with_catalog`] or one pointing at a sibling broadcast. + /// `config` is a [`json::Config`](crate::json::Config) for the `json` section, or an + /// application's own entry embedding a [`JsonConfig`](hang::catalog::JsonConfig) (see + /// [`IntoRendition`](super::IntoRendition)). The producer sets its `mode`, encodes the track + /// with its `compression`, and fills an absent `bitrate` from what it writes. A + /// [`delta_ratio`](crate::json::Config::delta_ratio) on `json::Config` selects the snapshot + /// encoder; it is not written into the catalog entry. The config is `'static` so that ratio + /// can be read off the builder. + /// + /// Errors if the entry's section already carries that name, for example an entry seeded + /// through [`Config::with_catalog`] or one pointing at a sibling broadcast, or if the entry + /// declares a compression this build can't write or references another broadcast. pub fn json_snapshot( &self, track: moq_net::track::Producer, - config: crate::json::Config, + config: impl super::IntoRendition + 'static, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::json::Snapshot::new(track, rendition, &config) + crate::json::Snapshot::new(track, rendition, config) } /// Publish `track` as an append-log JSON track, advertising it in the catalog. @@ -676,36 +684,37 @@ impl Producer { pub fn json_stream( &self, track: moq_net::track::Producer, - config: crate::json::Config, + config: impl super::IntoRendition, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::json::Stream::new(track, rendition, &config) + crate::json::Stream::new(track, rendition, config.into_rendition()) } /// Publish `track` as a latest-value binary track, advertising it in the catalog. /// /// See [`json_snapshot`](Self::json_snapshot) for the lifecycle; this differs only in that the - /// payloads are opaque bytes. + /// payloads are opaque bytes, and `config` is a [`binary::Config`](crate::binary::Config) or an + /// entry embedding a [`BinaryConfig`](hang::catalog::BinaryConfig). pub fn binary_snapshot( &self, track: moq_net::track::Producer, - config: crate::binary::Config, + config: impl super::IntoRendition, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::binary::Snapshot::new(track, rendition, &config) + crate::binary::Snapshot::new(track, rendition, config.into_rendition()) } /// Publish `track` as an append-log binary track, advertising it in the catalog. /// - /// See [`json_snapshot`](Self::json_snapshot) for the lifecycle; this differs only in that the - /// payloads are opaque bytes and every one is preserved rather than superseded. + /// See [`binary_snapshot`](Self::binary_snapshot); this differs only in that every payload is + /// preserved rather than superseded. pub fn binary_stream( &self, track: moq_net::track::Producer, - config: crate::binary::Config, + config: impl super::IntoRendition, ) -> crate::Result> { let rendition = self.data_entry(track.name())?; - crate::binary::Stream::new(track, rendition, &config) + crate::binary::Stream::new(track, rendition, config.into_rendition()) } /// Reserve the catalog entry a data producer owns, keyed by its track name. diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 95f9f78196..6c2c349fdb 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -7,52 +7,74 @@ use super::hang::{Catalog, CatalogExt}; /// A catalog config that can be published as a named rendition. /// -/// Implement it on your own config type to get the full catalog lifecycle through -/// [`Reserved::track`]: reservation gating, removal on drop, and optional jitter/bitrate detection. -/// [`VideoConfig`](hang::catalog::VideoConfig) and [`AudioConfig`](hang::catalog::AudioConfig) -/// implement it for every extension; a custom config implements it for the one [`CatalogExt`] that -/// holds it: +/// Implement it on your own config type to get the full catalog lifecycle: reservation gating, +/// removal on drop, and optional jitter/bitrate detection. [`VideoConfig`](hang::catalog::VideoConfig) +/// and [`AudioConfig`](hang::catalog::AudioConfig) implement it for every extension; a custom +/// config implements it for the one [`CatalogExt`] that holds it. Publish a media track under it +/// with [`Reserved::track`], or a data track with [`Producer::binary_stream`] and the like when it +/// embeds a data config (see [`IntoRendition`](super::IntoRendition)): /// /// ``` +/// # use std::collections::BTreeMap; +/// # use hang::catalog::{BinaryConfig, Mode}; /// # use moq_mux::catalog::{Estimate, RenditionConfig}; /// # use moq_mux::catalog::hang::{Catalog, CatalogExt}; /// # use serde::{Deserialize, Serialize}; -/// # use std::collections::BTreeMap; /// #[derive(Serialize, Deserialize, Clone, Default)] -/// struct MyExt { -/// telemetry: BTreeMap, +/// struct Ext { +/// #[serde(rename = "com.example.mavlink", default)] +/// mavlink: BTreeMap, /// } -/// impl CatalogExt for MyExt {} +/// impl CatalogExt for Ext {} /// -/// #[derive(Serialize, Deserialize, Clone, Default)] -/// struct Telemetry { -/// schema: String, -/// bitrate: Option, +/// #[derive(Serialize, Deserialize, Clone)] +/// struct Mavlink { +/// #[serde(flatten)] +/// binary: BinaryConfig, +/// sysid: u8, /// } /// -/// impl RenditionConfig for Telemetry { -/// fn detects() -> bool { -/// true +/// impl AsMut for Mavlink { +/// fn as_mut(&mut self) -> &mut BinaryConfig { +/// &mut self.binary /// } +/// } /// -/// fn insert(self, catalog: &mut Catalog, name: &str) { -/// catalog.ext.telemetry.insert(name.to_string(), self); +/// impl RenditionConfig for Mavlink { +/// fn insert(self, catalog: &mut Catalog, name: &str) { +/// catalog.ext.mavlink.insert(name.to_string(), self); /// } -/// fn get_mut<'a>(catalog: &'a mut Catalog, name: &str) -> Option<&'a mut Self> { -/// catalog.ext.telemetry.get_mut(name) +/// fn get_mut<'a>(catalog: &'a mut Catalog, name: &str) -> Option<&'a mut Self> { +/// catalog.ext.mavlink.get_mut(name) /// } -/// fn remove(catalog: &mut Catalog, name: &str) { -/// catalog.ext.telemetry.remove(name); +/// fn remove(catalog: &mut Catalog, name: &str) { +/// catalog.ext.mavlink.remove(name); /// } /// -/// // Opt into bitrate detection; jitter is left undetected. +/// // Opt into bitrate detection through the embedded config. +/// fn detects() -> bool { +/// true +/// } /// fn estimate(&self) -> Estimate { -/// Estimate::default().with_bitrate(self.bitrate) +/// Estimate::default().with_bitrate(self.binary.bitrate).with_jitter(self.binary.jitter) /// } /// fn set_estimate(&mut self, estimate: Estimate) { -/// self.bitrate = estimate.bitrate; +/// self.binary.bitrate = estimate.bitrate; +/// self.binary.jitter = estimate.jitter; /// } /// } +/// +/// # fn example( +/// # broadcast: &mut moq_net::broadcast::Producer, +/// # catalog: &moq_mux::catalog::Producer, +/// # ) -> moq_mux::Result<()> { +/// let track = broadcast.create_track("telemetry", None)?; +/// // The producer fixes the mode, so the one passed here is only a placeholder. +/// let entry = Mavlink { binary: BinaryConfig::new(Mode::Stream), sysid: 1 }; +/// let mut telemetry = catalog.binary_stream(track, entry)?; +/// telemetry.append(&b"\xfd..."[..])?; +/// # Ok(()) +/// # } /// ``` /// /// Note that `insert` takes the whole [`Catalog`], not just the extension, so the built-in media @@ -62,7 +84,7 @@ use super::hang::{Catalog, CatalogExt}; /// [`Reserved::track`] and [`Producer::track`](super::Producer::track) enroll the track in the /// broadcast timeline, measure it, and keep its estimate current automatically. pub trait RenditionConfig: Clone + Send + 'static { - /// Whether container writes should update this config's estimate fields. + /// Whether container or data-track writes should update this config's estimate fields. fn detects() -> bool { false } @@ -95,6 +117,17 @@ impl RenditionConfig for hang::catalog::JsonConfig { fn remove(catalog: &mut Catalog, name: &str) { catalog.json.tracks.remove(name); } + + fn detects() -> bool { + true + } + fn estimate(&self) -> Estimate { + Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + } + fn set_estimate(&mut self, estimate: Estimate) { + self.jitter = estimate.jitter; + self.bitrate = estimate.bitrate; + } } impl RenditionConfig for hang::catalog::BinaryConfig { @@ -107,6 +140,17 @@ impl RenditionConfig for hang::catalog::BinaryConfig { fn remove(catalog: &mut Catalog, name: &str) { catalog.binary.tracks.remove(name); } + + fn detects() -> bool { + true + } + fn estimate(&self) -> Estimate { + Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + } + fn set_estimate(&mut self, estimate: Estimate) { + self.jitter = estimate.jitter; + self.bitrate = estimate.bitrate; + } } /// Caller-provided catalog fields for a video track: a starting point for what the importer detects. diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs index 2929b0591e..39961a2773 100644 --- a/rs/moq-mux/src/clock.rs +++ b/rs/moq-mux/src/clock.rs @@ -59,10 +59,24 @@ impl Clock { /// enough that the wall value stays within the JSON-safe integer range until the year 2255. pub const TIMESCALE: moq_net::Timescale = moq_net::Timescale::MICRO; - /// Start a clock anchored at the current instant, with PTS zero at the current wall time. + /// How far before construction a fresh clock puts PTS zero. + /// + /// A translated source anchors its first frame at the current instant, but the frames + /// muxed beside it can carry earlier timestamps: a B-frame presenting before the keyframe + /// decoded ahead of it, or audio leading video in the mux. Starting the clock this far back + /// leaves them room instead of landing before the broadcast began. + const LEAD: Duration = Duration::from_secs(10); + + /// Start a clock at the current instant, with PTS zero ten seconds earlier on both the + /// monotonic and the wall clock, so earlier-stamped frames of a source anchored now still map. pub fn new() -> Self { - Self::at(Instant::now(), SystemTime::now()) - .expect("the current wall time is representable as a broadcast clock") + let (now, wall) = (Instant::now(), SystemTime::now()); + // Shortly after boot the monotonic clock may not reach back that far; start at now then. + let (epoch, wall) = match (now.checked_sub(Self::LEAD), wall.checked_sub(Self::LEAD)) { + (Some(epoch), Some(wall)) => (epoch, wall), + _ => (now, wall), + }; + Self::at(epoch, wall).expect("the current wall time is representable as a broadcast clock") } /// Start a clock at an explicit monotonic epoch and wall time. @@ -122,7 +136,7 @@ impl Default for Clock { /// live edge, preserving the source's spacing from there on; a reset re-anchors forward, /// preserving the real idle gap measured on the broadcast's monotonic clock. Backwards steps /// within [`MAX_REORDER`](Self::MAX_REORDER) keep their offset, so permitted B-frame reordering -/// inside a group survives verbatim. +/// inside a group survives verbatim. Translated timestamps keep the source's timescale. /// /// The broadcast wall mapping is never touched: translating a reset is not a new epoch, and a /// discontinuity marker the adapter emits alongside is a delivery event the playhead reacts to, @@ -131,13 +145,8 @@ impl Default for Clock { /// Each publisher adapter owns one per source and wires its own restart detection to /// [`reset`](Self::reset); the automatic path only separates reordering from resets by size. pub struct SourceMap { - clock: Clock, - /// Broadcast micros minus source micros; `None` until the first frame anchors it. - offset: Option, - last_source: Option, - last_broadcast: Option, - /// `clock.now()` when the last frame was translated: the idle gap's start. - last_arrival: Option, + anchor: Anchor, + lane: Lane, } impl SourceMap { @@ -152,22 +161,19 @@ impl SourceMap { /// A translator onto `clock`, unanchored until the first frame. pub fn new(clock: Clock) -> Self { Self { - clock, - offset: None, - last_source: None, - last_broadcast: None, - last_arrival: None, + anchor: Anchor::new(clock), + lane: Lane::default(), } } /// The broadcast clock this source translates onto. pub fn clock(&self) -> Clock { - self.clock + self.anchor.clock } /// Translate `pts` onto the broadcast clock, sampling the arrival time. pub fn translate(&mut self, pts: moq_net::Timestamp) -> crate::Result { - self.translate_at(pts, self.clock.now().value()) + self.anchor.translate(&mut self.lane, pts) } /// Translate `pts` onto the broadcast clock, arriving at monotonic `now` micros. @@ -175,41 +181,7 @@ impl SourceMap { /// The deterministic core behind [`translate`](Self::translate): synthetic sources pin the /// arrival instants instead of sampling them. pub fn translate_at(&mut self, pts: moq_net::Timestamp, now: u64) -> crate::Result { - let src = pts.as_micros(); - - let broadcast = match self.offset { - Some(offset) => { - let mapped = src as i128 + offset; - if mapped < 0 { - return Err(crate::Error::UnmappableTimestamp(format!( - "{pts:?} lands before the broadcast began" - ))); - } - let mapped = u64::try_from(mapped).map_err(|_| { - crate::Error::UnmappableTimestamp(format!("{pts:?} lands outside the representable range")) - })?; - match self.last_broadcast { - Some(last) if mapped < last && last - mapped > Self::MAX_REORDER.as_micros() as u64 => { - // A source reset: re-anchor forward, counting the downtime as content. - self.reanchor(src, now)? - } - // Forward, steady, or reordered within a group: the offset stands. - _ => mapped, - } - } - // The first frame is live now; the source keeps its spacing from there. Rebasing by - // the frame's own PTS (rather than pretending it is timestamp zero) is what keeps a - // delayed first frame honest. - None => { - self.offset = Some(now as i128 - src as i128); - now - } - }; - - self.last_source = Some(src); - self.last_broadcast = Some(broadcast); - self.last_arrival = Some(now); - moq_net::Timestamp::from_micros(broadcast).map_err(crate::Error::from) + self.anchor.translate_at(&mut self.lane, pts, now) } /// Re-anchor after an explicitly detected source restart, preserving the idle gap. @@ -218,33 +190,171 @@ impl SourceMap { /// the next frame continues after everything published so far plus the downtime since the /// previous frame, instead of rewinding the broadcast. pub fn reset(&mut self, pts: moq_net::Timestamp) -> crate::Result { - self.reset_at(pts, self.clock.now().value()) + self.lane.restart(); + self.translate(pts) } /// [`reset`](Self::reset) with an explicit arrival instant, for synthetic sources. pub fn reset_at(&mut self, pts: moq_net::Timestamp, now: u64) -> crate::Result { + self.lane.restart(); + self.translate_at(pts, now) + } +} + +/// One source's mapping onto the broadcast clock, shared by every track the source muxes. +/// +/// Tracks of one source must share an offset or they drift apart by however far their first +/// frames' PTS differ. They can't share a single [`SourceMap`] either: interleaved audio and +/// video step back further than [`SourceMap::MAX_REORDER`], which would read as a reset. So +/// each track keeps its own [`Lane`] that detects its own backwards steps, and a restart any +/// lane detects moves the anchor once; the other lanes adopt that mapping when they restart too. +pub(crate) struct Anchor { + clock: Clock, + /// Broadcast micros minus source micros for the current generation; `None` until the first + /// frame anchors it. + offset: Option, + /// Bumped at each re-anchor, so a lane knows whether its restart was already applied. + generation: u64, + /// The latest broadcast micros published so far, by any lane: the idle gap's origin. + last_broadcast: Option, + /// Where the latest frames end, by any lane, so a restart never lands on one of them. + last_end: Option, + /// `clock.now()` when the last frame was translated: the idle gap's start. + last_arrival: Option, +} + +/// One track's position on its source's [`Anchor`]. +#[derive(Default)] +pub(crate) struct Lane { + /// The offset this lane translates with, in micros; `None` until it adopts one. + offset: Option, + generation: u64, + last_source: Option, + /// The shortest forward step this lane's source took: its frame duration, near enough. + step: Option, + /// The adapter observed a restart on this lane out of band. + restart: bool, +} + +impl Lane { + /// The next frame starts a new source timeline, however its PTS compares to the last. + pub(crate) fn restart(&mut self) { + self.restart = true; + } +} + +impl Anchor { + pub(crate) fn new(clock: Clock) -> Self { + Self { + clock, + offset: None, + generation: 0, + last_broadcast: None, + last_end: None, + last_arrival: None, + } + } + + /// Translate one of `lane`'s timestamps, sampling the arrival time. + pub(crate) fn translate(&mut self, lane: &mut Lane, pts: moq_net::Timestamp) -> crate::Result { + self.translate_at(lane, pts, self.clock.now().value()) + } + + /// Translate one of `lane`'s timestamps, arriving at monotonic `now` micros. + pub(crate) fn translate_at( + &mut self, + lane: &mut Lane, + pts: moq_net::Timestamp, + now: u64, + ) -> crate::Result { let src = pts.as_micros(); - let broadcast = self.reanchor(src, now)?; - self.last_source = Some(src); - self.last_broadcast = Some(broadcast); + + match self.offset { + // The first frame is live now; the source keeps its spacing from there. Rebasing by + // the frame's own PTS (rather than pretending it is timestamp zero) is what keeps a + // delayed first frame honest. + None => self.offset = Some(now as i128 - src as i128), + Some(_) => { + let stepped_back = lane + .last_source + .is_some_and(|last| last > src + SourceMap::MAX_REORDER.as_micros()); + // A restart another lane already applied is adopted, not applied twice. + if (lane.restart || stepped_back) && lane.offset.is_some() && lane.generation == self.generation { + self.reanchor(src, now); + } + if lane.restart || stepped_back { + lane.offset = None; + } + } + } + lane.restart = false; + + // A lane joining late, or following a restart, takes the source's current mapping. + let offset = *lane.offset.get_or_insert_with(|| { + lane.generation = self.generation; + self.offset.expect("anchored above") + }); + + let mapped = src as i128 + offset; + if mapped < 0 { + return Err(crate::Error::UnmappableTimestamp(format!( + "{pts:?} lands before the broadcast began" + ))); + } + // The broadcast clock counts in micros, so the mapping must be nameable there too. + u64::try_from(mapped) + .ok() + .and_then(|mapped| moq_net::Timestamp::from_micros(mapped).ok()) + .ok_or_else(|| { + crate::Error::UnmappableTimestamp(format!("{pts:?} lands outside the representable range")) + })?; + + // Keep the source's timescale: the offset is constant, so the spacing stays exact. + let scale = pts.scale(); + let shift = offset * scale.as_u64() as i128 / 1_000_000; + let value = u64::try_from(pts.value() as i128 + shift) + .map_err(|_| crate::Error::UnmappableTimestamp(format!("{pts:?} lands outside the representable range")))?; + let translated = moq_net::Timestamp::new(value, scale) + .map_err(|_| crate::Error::UnmappableTimestamp(format!("{pts:?} lands outside the representable range")))?; + + if let Some(step) = lane + .last_source + .and_then(|last| src.checked_sub(last)) + .filter(|step| *step > 0) + { + lane.step = Some(lane.step.map_or(step, |min| min.min(step))); + } + lane.last_source = Some(src); + + let start = mapped as u128; + self.last_broadcast = Some(self.last_broadcast.map_or(start, |last| last.max(start))); + self.extend_micros(start + lane.step.unwrap_or(0)); self.last_arrival = Some(now); - moq_net::Timestamp::from_micros(broadcast).map_err(crate::Error::from) + Ok(translated) + } + + /// Record that the broadcast has published up to `end`, e.g. a fragment's last sample end. + pub(crate) fn extend(&mut self, end: moq_net::Timestamp) { + self.extend_micros(end.as_micros()); } - /// Move the offset so `src` continues after the last broadcast plus the idle gap since the - /// previous arrival. Returns the rebased broadcast micros. - fn reanchor(&mut self, src: u128, now: u64) -> crate::Result { + fn extend_micros(&mut self, end: u128) { + self.last_end = Some(self.last_end.map_or(end, |last| last.max(end))); + } + + /// Move the anchor so `src` continues after everything published plus the idle gap since the + /// previous arrival: the real downtime for a paced source, and at least the last frames' end + /// for one arriving in a burst. + fn reanchor(&mut self, src: u128, now: u64) { let base = match (self.last_broadcast, self.last_arrival) { - (Some(last), Some(arrival)) => last as u128 + now.saturating_sub(arrival) as u128, - // Unanchored: the reset frame itself is live now. + (Some(last), Some(arrival)) => { + let idle = last + now.saturating_sub(arrival) as u128; + idle.max(self.last_end.unwrap_or(0)) + } _ => now as u128, }; self.offset = Some(base as i128 - src as i128); - let broadcast = - u64::try_from(base).map_err(|_| crate::Error::UnmappableTimestamp(format!("{base} is out of range")))?; - // Refuse a mapping that contradicts the range instead of publishing it. - moq_net::Timestamp::from_micros(broadcast)?; - Ok(broadcast) + self.generation += 1; } } @@ -427,6 +537,81 @@ mod tests { )); } + #[test] + fn fresh_clock_leaves_room_before_now() { + let clock = Clock::new(); + // PTS zero sits before construction, so a frame stamped a little before now still maps, + // and the mapping still names the current wall time. + assert!(clock.now().as_micros() >= Clock::LEAD.as_micros()); + let now = clock.wall_clock(clock.now()).unwrap(); + let drift = now + .duration_since(SystemTime::now()) + .unwrap_or_else(|err| err.duration()); + assert!(drift < Duration::from_secs(1), "wall + now is the current wall time"); + } + + #[test] + fn muxed_lanes_share_one_mapping() { + let clock = Clock::at(epoch(), moq_epoch()).unwrap(); + let mut anchor = Anchor::new(clock); + let (mut video, mut audio) = (Lane::default(), Lane::default()); + + // Video anchors live; audio, muxed 800ms earlier, joins on the same offset. + let v = anchor.translate_at(&mut video, us(10_800_000), 2_000_000).unwrap(); + assert_eq!(v.as_micros(), 2_000_000); + let a = anchor.translate_at(&mut audio, us(10_000_000), 2_000_000).unwrap(); + assert_eq!(a.as_micros(), 1_200_000); + + // Interleaving steps back further than a reorder across lanes, which is not a reset. + let v = anchor.translate_at(&mut video, us(11_800_000), 3_000_000).unwrap(); + assert_eq!(v.as_micros(), 3_000_000); + let a = anchor.translate_at(&mut audio, us(11_000_000), 3_000_000).unwrap(); + assert_eq!(a.as_micros(), 2_200_000); + } + + #[test] + fn muxed_restart_reanchors_once() { + let clock = Clock::at(epoch(), moq_epoch()).unwrap(); + let mut anchor = Anchor::new(clock); + let (mut video, mut audio) = (Lane::default(), Lane::default()); + + anchor.translate_at(&mut video, us(5_000_000), 1_000_000).unwrap(); + anchor.translate_at(&mut audio, us(5_000_000), 1_000_000).unwrap(); + + // The source restarts at zero after 4s idle: video notices first and moves the anchor to + // the last published instant plus the gap. + let v = anchor.translate_at(&mut video, us(0), 5_000_000).unwrap(); + assert_eq!(v.as_micros(), 5_000_000); + // Audio's own step back adopts that mapping rather than adding the gap again. + let a = anchor.translate_at(&mut audio, us(20_000), 5_020_000).unwrap(); + assert_eq!(a.as_micros(), 5_020_000); + + // An out-of-band restart flagged on every lane is applied once as well. + video.restart(); + audio.restart(); + let v = anchor.translate_at(&mut video, us(0), 6_000_000).unwrap(); + assert_eq!(v.as_micros(), 6_000_000); + let a = anchor.translate_at(&mut audio, us(0), 6_000_000).unwrap(); + assert_eq!(a.as_micros(), 6_000_000); + } + + #[test] + fn translation_keeps_the_source_timescale() { + let clock = Clock::at(epoch(), moq_epoch()).unwrap(); + let mut source = clock.source(); + let scale = moq_net::Timescale::new(90_000).unwrap(); + + let first = source + .translate_at(moq_net::Timestamp::new(3003, scale).unwrap(), 1_000_000) + .unwrap(); + let second = source + .translate_at(moq_net::Timestamp::new(6006, scale).unwrap(), 1_033_000) + .unwrap(); + // 90 kHz in, 90 kHz out, with the frame spacing exact in ticks. + assert_eq!(first.scale(), scale); + assert_eq!(second.value() - first.value(), 3003); + } + #[test] fn mapping_before_the_broadcast_began_is_refused() { let clock = Clock::at(epoch(), moq_epoch()).unwrap(); diff --git a/rs/moq-mux/src/codec/opus/import.rs b/rs/moq-mux/src/codec/opus/import.rs index d32c33ae58..69b4631c86 100644 --- a/rs/moq-mux/src/codec/opus/import.rs +++ b/rs/moq-mux/src/codec/opus/import.rs @@ -114,7 +114,10 @@ impl Import { /// Build a catalog config from an OpusHead. Errors on a malformed or empty buffer. pub fn config(init: &[u8]) -> crate::Result { let mut buf = init; - Ok(Config::parse(&mut buf)?.into()) + let mut config: hang::catalog::AudioConfig = Config::parse(&mut buf)?.into(); + // Publish the head as given: re-encoding it would drop a channel mapping table. + config.description = Some(bytes::Bytes::copy_from_slice(init)); + Ok(config) } impl From for hang::catalog::AudioConfig { diff --git a/rs/moq-mux/src/codec/opus/mod.rs b/rs/moq-mux/src/codec/opus/mod.rs index bd1bba4a9b..a0c6ac0cbb 100644 --- a/rs/moq-mux/src/codec/opus/mod.rs +++ b/rs/moq-mux/src/codec/opus/mod.rs @@ -23,10 +23,29 @@ pub enum Error { #[error("invalid OpusHead signature")] InvalidSignature, - /// [`Config::encode`] was asked to emit an OpusHead for a channel count other - /// than mono or stereo; channel mapping family 0 only covers 1 or 2 channels. - #[error("channel mapping family 0 only supports mono/stereo (got {0} channels)")] + /// The OpusHead major version (the upper four bits) is not one this parser + /// understands, so none of its fields can be trusted (RFC 7845 §5.1). + #[error("unsupported OpusHead version {0}")] + UnsupportedVersion(u8), + + /// The channel count is zero, or more than the channel mapping family + /// allows: family 0 (the only one [`Config::encode`] emits) covers + /// mono/stereo, and family 1 up to eight channels. + #[error("channel mapping family does not allow {0} channels")] UnsupportedChannelCount(u32), + + /// A nonzero channel mapping family without its complete mapping table. + #[error("OpusHead channel mapping table is truncated")] + MappingTableTooShort, + + /// The channel mapping table names no streams, more coupled streams than + /// streams, or a channel index past the decoded streams. + #[error("invalid OpusHead channel mapping table")] + InvalidMappingTable, + + /// [`Config::encode`] only emits channel mapping family 0. + #[error("cannot encode channel mapping family {0}")] + UnsupportedMappingFamily(u8), } pub type Result = std::result::Result; @@ -35,21 +54,30 @@ pub type Result = std::result::Result; #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] pub struct Config { - /// Original input sample rate in Hz. + /// Original input sample rate in Hz, or 0 when unknown. + /// + /// Metadata only: Opus always runs on a 48 kHz clock, and this need not be a + /// rate Opus can decode at (44.1 kHz is common). pub sample_rate: u32, - /// Number of encoded channels. + /// Number of output channels. pub channel_count: u32, /// Number of decoded 48 kHz samples to discard at stream start. pub pre_skip: u16, + /// Gain to apply to the decoded output, in Q7.8 dB. + pub output_gain: i16, + /// Channel mapping family; 0 is mono/stereo with no mapping table. + pub mapping_family: u8, } impl Config { - /// Build a mono/stereo Opus config with no pre-skip. + /// Build a mono/stereo Opus config with no pre-skip or gain. pub fn new(sample_rate: u32, channel_count: u32) -> Self { Self { sample_rate, channel_count, pre_skip: 0, + output_gain: 0, + mapping_family: 0, } } @@ -61,8 +89,10 @@ impl Config { /// Parse an OpusHead buffer (RFC 7845 §5.1). /// - /// Verifies the magic signature; reads channel count, pre-skip, and sample - /// rate; ignores gain and channel mapping. Any trailing bytes are consumed. + /// Accepts any minor version and refuses a new major version. A nonzero + /// channel mapping family must carry a complete, consistent mapping table, + /// which is validated but not returned. Bytes after the header are left in + /// `buf`. pub fn parse(buf: &mut T) -> Result { if buf.remaining() < 19 { return Err(Error::HeadTooShort); @@ -72,31 +102,60 @@ impl Config { return Err(Error::InvalidSignature); } - buf.advance(1); // Skip version + let version = buf.get_u8(); + if version > 15 { + return Err(Error::UnsupportedVersion(version)); + } let channel_count = buf.get_u8() as u32; let pre_skip = buf.get_u16_le(); let sample_rate = buf.get_u32_le(); + let output_gain = buf.get_i16_le(); + let mapping_family = buf.get_u8(); + + let max_channels = match mapping_family { + 0 => 2, + 1 => 8, + _ => 255, + }; + if channel_count == 0 || channel_count > max_channels { + return Err(Error::UnsupportedChannelCount(channel_count)); + } - // Skip gain, channel mapping until if/when we support them. - if buf.remaining() > 0 { - buf.advance(buf.remaining()); + if mapping_family != 0 { + if buf.remaining() < 2 + channel_count as usize { + return Err(Error::MappingTableTooShort); + } + let streams = buf.get_u8() as u32; + let coupled = buf.get_u8() as u32; + if streams == 0 || coupled > streams || streams + coupled > 255 { + return Err(Error::InvalidMappingTable); + } + for _ in 0..channel_count { + // 255 marks a silent channel. + let index = buf.get_u8() as u32; + if index != 255 && index >= streams + coupled { + return Err(Error::InvalidMappingTable); + } + } } Ok(Self { sample_rate, channel_count, pre_skip, + output_gain, + mapping_family, }) } - /// Encode the minimal OpusHead packet (19 bytes; channel mapping family - /// 0 and zero gain). + /// Encode the minimal OpusHead packet (19 bytes; channel mapping family 0). /// - /// Errors with [`Error::UnsupportedChannelCount`] unless `channel_count` is 1 - /// or 2, since mapping family 0 is only defined for mono/stereo per RFC 7845 §5.1. - /// Multi-channel streams need family 1 with a channel mapping table, which - /// this helper does not emit. + /// Errors unless the mapping family is 0 and `channel_count` is 1 or 2, since + /// multichannel streams need a mapping table this helper does not emit. pub fn encode(&self) -> Result { + if self.mapping_family != 0 { + return Err(Error::UnsupportedMappingFamily(self.mapping_family)); + } if !(1..=2).contains(&self.channel_count) { return Err(Error::UnsupportedChannelCount(self.channel_count)); } @@ -106,7 +165,7 @@ impl Config { head.push(self.channel_count as u8); head.extend_from_slice(&self.pre_skip.to_le_bytes()); head.extend_from_slice(&self.sample_rate.to_le_bytes()); - head.extend_from_slice(&0i16.to_le_bytes()); // output gain + head.extend_from_slice(&self.output_gain.to_le_bytes()); head.push(0); // channel mapping family (0 = mono/stereo) Ok(Bytes::from(head)) } @@ -190,4 +249,106 @@ mod tests { let err = Config::new(48_000, 6).encode().unwrap_err(); assert!(matches!(err, Error::UnsupportedChannelCount(6))); } + + /// A 19-byte family 0 head with the given version, channels, gain, and family. + fn head(version: u8, channels: u8, gain: i16, family: u8) -> Vec { + let mut head = b"OpusHead".to_vec(); + head.push(version); + head.push(channels); + head.extend_from_slice(&312u16.to_le_bytes()); + head.extend_from_slice(&44_100u32.to_le_bytes()); + head.extend_from_slice(&gain.to_le_bytes()); + head.push(family); + head + } + + #[test] + fn parses_gain_and_input_rate() { + let bytes = head(1, 2, -1536, 0); + let parsed = Config::parse(&mut bytes.as_slice()).unwrap(); + assert_eq!(parsed.sample_rate, 44_100); + assert_eq!(parsed.pre_skip, 312); + assert_eq!(parsed.output_gain, -1536); + assert_eq!(parsed.mapping_family, 0); + + // The gain survives a round trip. + let encoded = parsed.encode().unwrap(); + assert_eq!(encoded.as_ref(), bytes.as_slice()); + } + + #[test] + fn parse_accepts_minor_versions_only() { + for version in [0, 1, 15] { + assert!(Config::parse(&mut head(version, 2, 0, 0).as_slice()).is_ok()); + } + assert!(matches!( + Config::parse(&mut head(16, 2, 0, 0).as_slice()), + Err(Error::UnsupportedVersion(16)) + )); + } + + #[test] + fn parse_rejects_channel_counts_the_family_does_not_allow() { + for (channels, family) in [(0, 0), (3, 0), (0, 1), (9, 1)] { + assert!( + matches!( + Config::parse(&mut head(1, channels, 0, family).as_slice()), + Err(Error::UnsupportedChannelCount(_)) + ), + "{channels} channels in family {family}" + ); + } + } + + #[test] + fn parse_reads_a_mapping_table() { + // 5.1 in family 1: four streams, two coupled, Vorbis order. + let mut bytes = head(1, 6, 0, 1); + bytes.extend_from_slice(&[4, 2, 0, 4, 1, 2, 3, 5]); + bytes.extend_from_slice(b"trailing"); + let mut buf = bytes.as_slice(); + let parsed = Config::parse(&mut buf).unwrap(); + assert_eq!(parsed.channel_count, 6); + assert_eq!(parsed.mapping_family, 1); + assert_eq!(buf, b"trailing"); + + // Encode only emits family 0. + assert!(matches!(parsed.encode(), Err(Error::UnsupportedMappingFamily(1)))); + } + + #[test] + fn parse_rejects_a_truncated_or_invalid_mapping_table() { + // No table at all. + let bytes = head(1, 2, 0, 1); + assert!(matches!( + Config::parse(&mut bytes.as_slice()), + Err(Error::MappingTableTooShort) + )); + + // Table cut short of its last channel. + let mut bytes = head(1, 2, 0, 1); + bytes.extend_from_slice(&[1, 1, 0]); + assert!(matches!( + Config::parse(&mut bytes.as_slice()), + Err(Error::MappingTableTooShort) + )); + + for table in [ + [0, 0, 0, 1], // no streams + [1, 2, 0, 1], // more coupled than streams + [1, 1, 0, 2], // index past the two decoded channels + ] { + let mut bytes = head(1, 2, 0, 1); + bytes.extend_from_slice(&table); + assert!( + matches!(Config::parse(&mut bytes.as_slice()), Err(Error::InvalidMappingTable)), + "{table:?}" + ); + } + + // 255 is a silent channel, not an index. + let mut bytes = head(1, 2, 0, 1); + bytes.extend_from_slice(&[1, 1, 0, 255]); + assert!(Config::parse(&mut bytes.as_slice()).is_ok()); + } } diff --git a/rs/moq-mux/src/container/flv/import.rs b/rs/moq-mux/src/container/flv/import.rs index 5ba6fc30e3..d4d7fe267b 100644 --- a/rs/moq-mux/src/container/flv/import.rs +++ b/rs/moq-mux/src/container/flv/import.rs @@ -80,6 +80,10 @@ pub struct Import { video: BTreeMap, /// Demuxed audio tracks keyed by RTMP track id. audio: BTreeMap, + + /// The source's mapping onto the broadcast clock, set by [`live`](Self::live). `None` + /// publishes the tag timestamps verbatim. + anchor: Option, } /// The demuxed video track plus its current catalog config, so a repeated @@ -89,12 +93,14 @@ struct VideoStream { config: VideoConfig, stalled: hang::catalog::stalled::Detector, last_source: Option, + lane: crate::clock::Lane, } /// The demuxed audio track plus its current catalog config. struct AudioStream { track: crate::container::Producer, config: AudioConfig, + lane: crate::clock::Lane, } impl Import { @@ -110,9 +116,22 @@ impl Import { header_seen: false, video: BTreeMap::new(), audio: BTreeMap::new(), + anchor: None, } } + /// Publish on the broadcast clock rather than the source's own tag timestamps. + /// + /// For a live feed with its own zero: the first frame is live on arrival, every track shares + /// that one mapping, and an encoder that restarts its timestamps continues forward after the + /// real idle gap. Without this, tag timestamps are published verbatim, which suits a source + /// already on the clock the catalog advertises + /// ([`Config::with_clock`](crate::catalog::Config::with_clock)). + pub fn live(mut self) -> Self { + self.anchor = Some(crate::clock::Anchor::new(self.catalog.clock())); + self + } + /// Select the container this importer wraps decoded media renditions in. /// /// [`Legacy`](hang::catalog::Container::Legacy) unless selected. It applies to every rendition @@ -470,9 +489,13 @@ impl Import { // A media frame means every sequence header has arrived (FLV sends config before data), so // the track set is declared; release the reservation to publish. self.initial_reservation = None; - let timestamp = Timestamp::from_millis(pts_ms as u64)?; let written = { let stream = self.video.get_mut(&track_id).expect("checked above"); + let timestamp = Timestamp::from_millis(pts_ms as u64)?; + let timestamp = match self.anchor.as_mut() { + Some(anchor) => anchor.translate(&mut stream.lane, timestamp)?, + None => timestamp, + }; match stream.track.write(Frame { timestamp, duration: None, @@ -502,8 +525,13 @@ impl Import { // A media frame means every sequence header has arrived (FLV sends config before data), so // the track set is declared; release the reservation to publish. self.initial_reservation = None; + let timestamp = Timestamp::from_millis(timestamp)?; + let timestamp = match self.anchor.as_mut() { + Some(anchor) => anchor.translate(&mut stream.lane, timestamp)?, + None => timestamp, + }; stream.track.write(Frame { - timestamp: Timestamp::from_millis(timestamp)?, + timestamp, duration: None, payload: Bytes::copy_from_slice(data), keyframe: true, @@ -537,6 +565,7 @@ impl Import { config, stalled: hang::catalog::stalled::Detector::new(), last_source: None, + lane: Default::default(), }, ); Ok(()) @@ -558,7 +587,14 @@ impl Import { Some(reserved) => reserved.audio(net_track, wire, config.clone())?, None => self.catalog.audio(net_track, wire, config.clone())?, }; - self.audio.insert(track_id, AudioStream { track: media, config }); + self.audio.insert( + track_id, + AudioStream { + track: media, + config, + lane: Default::default(), + }, + ); Ok(()) } @@ -628,9 +664,11 @@ impl Import { pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { for stream in self.video.values_mut() { stream.track.seek(sequence)?; + stream.lane.restart(); } for stream in self.audio.values_mut() { stream.track.seek(sequence)?; + stream.lane.restart(); } Ok(()) } diff --git a/rs/moq-mux/src/container/flv/import_test.rs b/rs/moq-mux/src/container/flv/import_test.rs index a7cddfde56..ceb6a78897 100644 --- a/rs/moq-mux/src/container/flv/import_test.rs +++ b/rs/moq-mux/src/container/flv/import_test.rs @@ -664,3 +664,145 @@ async fn import_rejects_non_flv() { let buf = bytes::BytesMut::from(&b"NOTFLV\x00\x00\x00"[..]); assert!(importer.decode(&buf).is_err()); } + +/// One encoder session: sequence headers (after the file header when `header`), then `frames` +/// video frames 40ms apart whose composition offsets reorder like B-frames, interleaved with AAC +/// frames up to 300ms earlier in PTS, the way a muxer leads audio. +fn session(header: bool, start_ms: u32, frames: u32) -> Vec { + let mut out = if header { flv_header(0x05) } else { Vec::new() }; + + let mut vseq = vec![ + (super::FRAME_TYPE_KEY << 4) | super::VIDEO_CODEC_AVC, + super::AVC_SEQUENCE_HEADER, + 0, + 0, + 0, + ]; + vseq.extend_from_slice(&avcc()); + write_tag(&mut out, super::TAG_VIDEO, start_ms, &vseq); + let mut aseq = vec![super::AAC_AUDIO_TAG_HEADER, super::AAC_SEQUENCE_HEADER]; + aseq.extend_from_slice(&ASC); + write_tag(&mut out, super::TAG_AUDIO, start_ms, &aseq); + + for i in 0..frames { + let dts = start_ms + i * 40; + let frame_type = if i % 25 == 0 { + super::FRAME_TYPE_KEY + } else { + super::FRAME_TYPE_INTER + }; + // IPBPB...: P-frames present two slots late and the B-frames between them step back. + let cts: u8 = match i % 25 { + 0 => 40, + j if j % 2 == 1 => 80, + _ => 0, + }; + let mut video = vec![(frame_type << 4) | super::VIDEO_CODEC_AVC, super::AVC_NALU, 0, 0, cts]; + video.extend_from_slice(&[0, 0, 0, 5, 0x65, 0x88, 0x84, 0x21, 0x00]); + write_tag(&mut out, super::TAG_VIDEO, dts, &video); + + let mut audio = vec![super::AAC_AUDIO_TAG_HEADER, super::AAC_RAW]; + audio.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + write_tag(&mut out, super::TAG_AUDIO, dts + 20 - start_ms.min(300), &audio); + } + out +} + +/// What an FLV import published, plus the broadcast clock's reading around the first chunk. +struct Imported { + published: std::collections::BTreeMap>, + video: String, + before: u128, + after: u128, +} + +/// Import `chunks` in order, idling `idle` between them, on a clock that began `ago` earlier. +/// `live` translates onto that clock; otherwise tag timestamps publish verbatim. +async fn import(chunks: &[Vec], live: bool, ago: Duration, idle: Duration) -> Imported { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let config = crate::catalog::Config::default().with_clock(crate::container::test_util::late_clock(ago)); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + let clock = catalog.clock(); + let mut importer = Import::new(broadcast, catalog.reserve()); + if live { + importer = importer.live(); + } + + let before = clock.now().as_micros(); + let mut after = before; + for (i, chunk) in chunks.iter().enumerate() { + if i > 0 { + std::thread::sleep(idle); + } + importer.decode(chunk).unwrap(); + if i == 0 { + after = clock.now().as_micros(); + } + } + importer.finish().unwrap(); + + let snapshot = catalog.snapshot(); + Imported { + published: crate::container::test_util::published(&consumer, &snapshot).await, + video: snapshot.video.renditions.keys().next().unwrap().clone(), + before, + after, + } +} + +/// A feed an hour into its own timeline, arriving 30s after the broadcast began, publishes on the +/// broadcast clock: the first frame is live on arrival, and audio and video keep the one offset +/// their tags gave them, B-frame reordering included. +#[tokio::test] +async fn live_import_anchors_a_late_first_frame() { + let input = [session(true, 3_600_000, 50)]; + let ago = Duration::from_secs(30); + let verbatim = import(&input, false, ago, Duration::ZERO).await; + let live = import(&input, true, ago, Duration::ZERO).await; + + crate::container::test_util::common_offset(&verbatim.published, &live.published); + let first = live.published[&live.video][0]; + assert!( + (live.before..=live.after).contains(&first), + "the first frame is live on arrival: {first} not in {}..={}", + live.before, + live.after + ); +} + +/// An encoder restarting its timestamps at zero continues the broadcast forward: after the real +/// idle gap, and with every track moving onto the one new mapping. +#[tokio::test] +async fn live_import_restarts_forward_after_idle() { + for idle in [Duration::ZERO, Duration::from_millis(300)] { + let input = [session(true, 5_000, 50), session(false, 0, 50)]; + let live = import(&input, true, Duration::from_secs(30), idle).await; + + let last_before = live + .published + .values() + .map(|t| t[..50].iter().max().unwrap()) + .max() + .unwrap(); + let video = &live.published[&live.video]; + let gap = video[50] as i128 - *last_before as i128; + assert!( + gap >= idle.as_micros() as i128, + "the restart lands after the idle gap: {gap}us after {idle:?}" + ); + assert!( + gap < (idle + Duration::from_secs(5)).as_micros() as i128, + "the restart is not pushed further: {gap}us" + ); + + // The second session keeps its own A/V relationship on the new mapping. + let verbatim = import(&[session(true, 0, 50)], false, Duration::ZERO, Duration::ZERO).await; + let second = live + .published + .iter() + .map(|(name, t)| (name.clone(), t[50..].to_vec())) + .collect(); + crate::container::test_util::common_offset(&verbatim.published, &second); + } +} diff --git a/rs/moq-mux/src/container/fmp4/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 714845fef8..15d2cf26ea 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -89,6 +89,10 @@ pub struct Import { // Only the timeline report is anchored. Each fragment still carries its own timestamp on the // wire, and `Recorder::end` still reports real content time. segment_start: Option, + + // The source's mapping onto the broadcast clock, set by `live`. `None` publishes the source's + // decode times verbatim. + anchor: Option, } /// The catalog entry for one imported track, whichever section it lives in. @@ -132,6 +136,9 @@ struct Fmp4Track { // Sequence to use for the next group, set by `Import::seek`. pending_sequence: Option, + // This track's position on the source's broadcast-clock mapping. + lane: crate::clock::Lane, + // The segment this track's open group belongs to. A mismatch with `Import::segment` rolls the // group, which is what keeps audio on the same boundaries as video. segment: Option, @@ -174,9 +181,22 @@ impl Import { segment: 0, pending_timeline_cut: false, segment_start: None, + anchor: None, } } + /// Publish on the broadcast clock rather than the source's own decode times. + /// + /// For a live feed with its own zero: the first fragment is live on arrival, every track + /// shares that one mapping, and a source that restarts its decode times continues forward + /// after the real idle gap instead of being refused. Each fragment's `tfdt` is rewritten to + /// match. Without this, decode times are published verbatim, which suits a source already on + /// the clock the catalog advertises ([`Config::with_clock`](crate::catalog::Config::with_clock)). + pub fn live(mut self) -> Self { + self.anchor = Some(crate::clock::Anchor::new(self.catalog.clock())); + self + } + /// Declare that the next fragment starts a new segment, for callers that know the source's /// segmentation out of band (e.g. an HLS import following its playlist). /// @@ -347,6 +367,7 @@ impl Import { last_decode_time: None, sample_duration: None, pending_sequence: None, + lane: Default::default(), estimator: Estimator::new(), claim: crate::catalog::Claim::new(self.catalog.bandwidth()), }, @@ -698,8 +719,16 @@ impl Import { let default_sample_flags = trex.map(|trex| trex.default_sample_flags).unwrap_or_default(); let tfdt = traf.tfdt.as_ref().ok_or(Error::MissingTfdt)?; - let mut dts = tfdt.base_media_decode_time; let timescale = moq_net::Timescale::new(trak.mdia.mdhd.timescale as u64)?; + // The decode time this fragment is published at, and so rewritten into its `tfdt`. + let base_decode_time = match self.anchor.as_mut() { + Some(anchor) => { + let source = Timestamp::new(tfdt.base_media_decode_time, timescale)?; + anchor.translate(&mut track.lane, source)?.value() + } + None => tfdt.base_media_decode_time, + }; + let mut dts = base_decode_time; // Every fragment restates its decode time, so a stale one puts two different samples // on the same timestamp, which reads downstream as an undeclared hole. @@ -845,6 +874,9 @@ impl Import { // and ensuring trun.data_offset is Some(...) reserves 4 bytes per trun. for traf_mut in &mut adjusted_moof.traf { traf_mut.tfhd.base_data_offset = None; + traf_mut.tfdt = Some(mp4_atom::Tfdt { + base_media_decode_time: base_decode_time, + }); // A zero default/sample duration is "unknown", not "instantaneous": drop it so // the re-emitted fragment carries no bogus zero that a decoder would honor. if traf_mut.tfhd.default_sample_duration == Some(0) { @@ -962,6 +994,10 @@ impl Import { track.estimator.write(timestamp, fragment_len); let end = max_end.ok_or(Error::MissingTrun)?; + if let Some(anchor) = self.anchor.as_mut() { + // A restart continues after this fragment's last sample, not merely its start. + anchor.extend(end); + } if let Some(recorder) = track.recorder.as_mut() { recorder.end(end); } @@ -1013,6 +1049,7 @@ impl Import { } track.pending_sequence = Some(sequence); track.last_decode_time = None; + track.lane.restart(); } Ok(()) } diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index 9f0108e436..9d54992e80 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -1192,3 +1192,157 @@ fn fragment_jitter_uses_sample_endpoints() { assert_eq!(jitter.as_nanos().div_ceil(1_000_000), expected); } } + +/// One encoder session of bbb-shaped fragments: `frames` 100ms video fragments from `start_us`, +/// a keyframe each second, interleaved with audio fragments up to 300ms earlier in PTS. +fn live_session(start_us: u64, frames: u64) -> Vec { + let (_, (video_id, video_scale), (audio_id, audio_scale)) = bbb_init(); + let lead = start_us.min(300_000); + let mut out = Vec::new(); + for j in 0..frames { + let pts = start_us + j * 100_000; + let video = sample(pts, j % 10 == 0, Some(100_000)); + out.extend_from_slice(&super::encode_fragment(info(video_id, video_scale, j as u32), &[video]).unwrap()); + let audio = sample(pts - lead, true, Some(100_000)); + out.extend_from_slice(&super::encode_fragment(info(audio_id, audio_scale, j as u32), &[audio]).unwrap()); + } + out +} + +/// What an fMP4 import published, plus the broadcast clock's reading around the first chunk. +struct LiveImport { + published: std::collections::BTreeMap>, + video: String, + clock: crate::Clock, + before: u128, + after: u128, + /// The first segment the broadcast timeline recorded, the index an archive replays from. + record: moq_net::Timestamp, +} + +/// Import bbb's init then `chunks`, idling `idle` between them, on a clock that began `ago` +/// earlier. `live` translates onto that clock; otherwise decode times publish verbatim. +async fn live_import( + chunks: &[Vec], + live: bool, + ago: std::time::Duration, + idle: std::time::Duration, +) -> LiveImport { + let (init, _, _) = bbb_init(); + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let config = crate::catalog::Config::default().with_clock(crate::container::test_util::late_clock(ago)); + let mut catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + let clock = catalog.clock(); + let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()); + if live { + fmp4 = fmp4.live(); + } + fmp4.decode(&init).unwrap(); + + let snapshot = catalog.snapshot(); + let section = snapshot.archive.clone().expect("the import advertises a timeline"); + let mut timeline = crate::timeline::Consumer::<()>::subscribe(&consumer, §ion) + .await + .unwrap(); + + let before = clock.now().as_micros(); + let mut after = before; + for (i, chunk) in chunks.iter().enumerate() { + if i > 0 { + std::thread::sleep(idle); + } + fmp4.decode(chunk).unwrap(); + if i == 0 { + after = clock.now().as_micros(); + } + } + fmp4.finish().unwrap(); + catalog.finish().unwrap(); + + let event = timeline.next().await.unwrap().expect("a recorded segment"); + let crate::timeline::Event::Push { entry, .. } = event else { + panic!("the first timeline event was not a segment"); + }; + + LiveImport { + published: crate::container::test_util::published(&consumer, &snapshot).await, + video: snapshot.video.renditions.keys().next().unwrap().clone(), + clock, + before, + after, + record: entry.pts, + } +} + +/// A feed an hour into its own decode timeline, arriving 30s after the broadcast began, publishes +/// on the broadcast clock: the `tfdt` a decoder reads is rewritten so the first fragment is live +/// on arrival, and every track moves by the one offset. The archive index records the same times, +/// so a replay names each segment's real wall time. +#[tokio::test] +async fn live_import_rewrites_tfdt_onto_the_broadcast_clock() { + let input = [live_session(3_600_000_000, 20)]; + let ago = std::time::Duration::from_secs(30); + let verbatim = live_import(&input, false, ago, std::time::Duration::ZERO).await; + let wall_before = std::time::SystemTime::now(); + let live = live_import(&input, true, ago, std::time::Duration::ZERO).await; + let wall_after = std::time::SystemTime::now(); + + crate::container::test_util::common_offset(&verbatim.published, &live.published); + let first = live.published[&live.video][0]; + assert!( + (live.before..=live.after).contains(&first), + "the first fragment is live on arrival: {first} not in {}..={}", + live.before, + live.after + ); + + // The timeline records at a coarser scale, so allow its rounding. + let tick = std::time::Duration::from_millis(1); + assert!( + first.abs_diff(live.record.as_micros()) < tick.as_micros(), + "the archive indexes the translated time" + ); + let wall = live.clock.wall_clock(live.record).unwrap(); + assert!( + wall_before - tick <= wall && wall <= wall_after, + "a replayed segment names the wall time it went live" + ); +} + +/// A source whose decode times restart at zero continues forward after the real idle gap rather +/// than being refused as non-monotonic, with every track moving onto one new mapping. +#[tokio::test] +async fn live_import_restarts_forward_after_idle() { + for idle in [std::time::Duration::ZERO, std::time::Duration::from_millis(300)] { + let input = [live_session(5_000_000, 20), live_session(0, 20)]; + let live = live_import(&input, true, std::time::Duration::from_secs(30), idle).await; + + // The restart lands the arrival gap after the first session's last fragment, and never on + // top of it: that fragment lasts 100ms. + let last = live + .published + .values() + .map(|t| t[..20].iter().max().unwrap()) + .max() + .unwrap(); + let gap = live.published[&live.video][20] as i128 - *last as i128; + let floor = idle.max(std::time::Duration::from_millis(100)); + assert!( + gap >= floor.as_micros() as i128, + "the restart lands after the idle gap: {gap}us after {idle:?}" + ); + assert!( + gap < (idle + std::time::Duration::from_secs(5)).as_micros() as i128, + "the restart is not pushed further: {gap}us" + ); + + let verbatim = live_import(&input[1..], false, std::time::Duration::ZERO, std::time::Duration::ZERO).await; + let second = live + .published + .iter() + .map(|(name, t)| (name.clone(), t[20..].to_vec())) + .collect(); + crate::container::test_util::common_offset(&verbatim.published, &second); + } +} diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index ba4af3a2c5..fa18792c30 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -490,6 +490,12 @@ where /// must be a keyframe. An explicit bound before the last ordered video frame /// returns [`InvalidEnd`](super::InvalidEnd) without flushing or closing the group. pub fn cut(&mut self, end: Option) -> crate::Result<()> { + let marker_at = end.or_else(|| self.estimated_end()); + self.close(end, marker_at) + } + + /// Close the current group, ending a video track's group with a duration marker at `marker_at`. + fn close(&mut self, end: Option, marker_at: Option) -> crate::Result<()> { if self.container.kind() == Kind::Video && !self.reordered && let Some((end, previous)) = end.zip(self.previous_timestamp) @@ -503,8 +509,6 @@ where self.estimator.cut(end); self.claim(); - let marker_at = end.or_else(|| self.estimated_end()); - // Tell the timeline where this group's content stops: the duration marker when we // write one, else the caller's bound, else the furthest point we wrote. if let Some(recorder) = self.recorder.as_mut() @@ -603,11 +607,12 @@ where /// an empty payload is data. The next [`write`](Self::write) opens the group after the /// marker and must continue forward from the live edge; it cannot rewind. /// - /// To bound the closing group's final frame, [`cut(end)`](Self::cut) before calling this; - /// the open group is closed either way (an unbounded [`cut`](Self::cut) here is a no-op - /// after yours). + /// To bound the closing group's final frame, [`cut(end)`](Self::cut) before calling this. + /// Otherwise the open group closes without a duration marker: what resumes may land sooner + /// than one estimated frame later (a capture that reopens at once), and a guessed end past + /// it would read as a rewind to every consumer. pub fn discontinuity(&mut self) -> crate::Result<()> { - self.cut(None)?; + self.close(None, None)?; // Nothing is measured across the break: the frames still open on this side have no end, and // the gap to the far side is not a frame duration. self.estimator.discontinuity(); @@ -1488,4 +1493,28 @@ mod tests { let groups = collect_payloads(consumer).await; assert_eq!(groups.last().unwrap().last(), Some(&(1_060_000, 0))); } + + /// A capture that reopens at once resumes sooner than one frame after the break. Guessing + /// the closing group's end from its cadence would put a duration marker past the resumed + /// keyframe, which a consumer reads as a rewind and refuses. + #[tokio::test] + async fn a_prompt_resume_after_a_discontinuity_is_not_a_rewind() { + let track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); + let subscriber = track.subscribe(replay()); + let mut producer = Producer::new(track, Container::Legacy(crate::container::Kind::Video)); + producer.write(frame(1_000_000, true)).unwrap(); + producer.write(frame(1_040_000, false)).unwrap(); + producer.discontinuity().unwrap(); + // Resumed 10ms later, inside the 40ms cadence measured before the break. + producer.write(frame(1_050_000, true)).unwrap(); + producer.finish().unwrap(); + + let mut consumer = + crate::container::Consumer::new(subscriber, Container::Legacy(crate::container::Kind::Video)); + let mut timestamps = Vec::new(); + while let Some(frame) = consumer.read().await.unwrap() { + timestamps.push(frame.timestamp.as_micros()); + } + assert_eq!(timestamps, [1_000_000, 1_040_000, 1_050_000]); + } } diff --git a/rs/moq-mux/src/container/test_util.rs b/rs/moq-mux/src/container/test_util.rs index c95462d376..50d5f4de97 100644 --- a/rs/moq-mux/src/container/test_util.rs +++ b/rs/moq-mux/src/container/test_util.rs @@ -128,3 +128,65 @@ pub(crate) fn raw_frame(timestamp_us: u64, payload: &'static [u8], keyframe: boo duration: None, } } + +/// A broadcast clock that began `ago` before now, so a first frame arriving now reads as late. +pub(crate) fn late_clock(ago: std::time::Duration) -> crate::Clock { + crate::Clock::at(std::time::Instant::now() - ago, std::time::SystemTime::now() - ago).unwrap() +} + +/// Every frame timestamp, in micros, that each media rendition in `catalog` published, by track. +/// +/// Reads through each rendition's own container, so an fMP4 timestamp comes from the fragment's +/// `tfdt` rather than the wire header. The importer must have finished its tracks. +pub(crate) async fn published( + consumer: &moq_net::broadcast::Consumer, + catalog: &hang::catalog::Catalog, +) -> std::collections::BTreeMap> { + let mut containers = Vec::new(); + for (name, config) in &catalog.video.renditions { + containers.push((name.clone(), crate::catalog::hang::Container::try_from(config).unwrap())); + } + for (name, config) in &catalog.audio.renditions { + containers.push((name.clone(), crate::catalog::hang::Container::try_from(config).unwrap())); + } + + let mut out = std::collections::BTreeMap::new(); + for (name, container) in containers { + let replay = moq_net::track::Subscription::default().with_max_age(std::time::Duration::from_secs(3600)); + let track = consumer.track(&name).unwrap().subscribe(replay).await.unwrap(); + let mut reader = crate::container::Consumer::new(track, container); + let mut timestamps = Vec::new(); + while let Some(frame) = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read()) + .await + .expect("the importer finished its tracks") + .unwrap() + { + timestamps.push(frame.timestamp.as_micros()); + } + out.insert(name, timestamps); + } + out +} + +/// The one offset every published timestamp moved by between a verbatim and a live import of the +/// same input, within a tick of rounding: one mapping for every track, so A/V sync and B-frame +/// order survive exactly. +pub(crate) fn common_offset( + verbatim: &std::collections::BTreeMap>, + live: &std::collections::BTreeMap>, +) -> i128 { + assert_eq!(verbatim.keys().count(), live.keys().count(), "the same tracks publish"); + let mut offset = None; + for (v, l) in verbatim.values().zip(live.values()) { + assert_eq!(v.len(), l.len(), "the same frames publish"); + for (v, l) in v.iter().zip(l) { + let delta = *l as i128 - *v as i128; + let first = *offset.get_or_insert(delta); + assert!( + (delta - first).abs() <= 1_000, + "every frame moves by one offset: {delta} vs {first}" + ); + } + } + offset.expect("frames were published") +} diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index d811e24cc1..0cfa35a533 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -134,6 +134,9 @@ pub struct Import { /// advances it, so a cue could be stamped with another program's PTS. last_pts: Option, media_unwrap: PtsUnwrap, + /// The source's mapping onto the broadcast clock, set by [`live`](Self::live). `None` + /// publishes the source's unwrapped PTS verbatim. + anchor: Option, } impl Import { @@ -176,9 +179,22 @@ impl Import { identity_recorded: false, last_pts: None, media_unwrap: PtsUnwrap::default(), + anchor: None, } } + /// Publish on the broadcast clock rather than the source's own PTS. + /// + /// For a live feed with its own zero: the first frame is live on arrival, every elementary + /// stream shares that one mapping, and a restart (a PTS rewind or a signalled time-base + /// discontinuity) continues forward after the real idle gap. Without this, the unwrapped PTS + /// is published verbatim, which suits a source already on the clock the catalog advertises + /// ([`Config::with_clock`](crate::catalog::Config::with_clock)). + pub fn live(mut self) -> Self { + self.anchor = Some(crate::clock::Anchor::new(self.catalog.clock())); + self + } + /// Select the container this importer wraps decoded media renditions in. /// /// [`Legacy`](hang::catalog::Container::Legacy) unless selected. It applies to every rendition @@ -646,7 +662,11 @@ impl Import { // frame must be timestamped with this frame's PTS ("now"), not the // previous one's. if pes.header.pts.is_some() { - let pts = unwrap_pts(&mut self.media_unwrap, pes.header.pts.map(|t| t.as_u64()))?; + let pts = unwrap_pts( + &mut self.media_unwrap, + pes.header.pts.map(|t| t.as_u64()), + self.anchor.as_mut(), + )?; let video = match self.streams.get(&pid) { Some(Stream::H264 { reanchor, import, .. }) => { Some((reanchor, import.floor(false), import.floor(true))) @@ -717,7 +737,7 @@ impl Import { let Some(stream) = self.streams.get_mut(&pid) else { return Ok(()); }; - self.published |= stream.write(pending, batched)?; + self.published |= stream.write(pending, batched, self.anchor.as_mut())?; // Record the decoded media track's PID + PMT descriptors (language, ...) once // its lazily created track exists, so export can preserve them. @@ -1219,7 +1239,7 @@ impl VerbatimStream { /// Publish one reassembled PES payload verbatim, in its own group, stamped with /// its PTS (or the live edge when the PES carried none). - fn write(&mut self, pending: Pending) -> anyhow::Result { + fn write(&mut self, pending: Pending, anchor: Option<&mut crate::clock::Anchor>) -> anyhow::Result { // Record the original PES stream_id once, from the first PES, so export // re-emits the stream under its real id (e.g. 0xBD for teletext/DVB AC-3). if !self.stream_id_recorded { @@ -1233,7 +1253,7 @@ impl VerbatimStream { } let edge = self.track.live_edge(); - let pts = match unwrap_pts(&mut self.unwrap, pending.pts)? { + let pts = match unwrap_pts(&mut self.unwrap, pending.pts, anchor)? { Some(pts) => self.reanchor.apply(pts, edge)?, // No clock to shift, so land on the edge and leave the shift alone. None => edge.unwrap_or(Timestamp::ZERO), @@ -1547,7 +1567,12 @@ enum Stream { } impl Stream { - fn write(&mut self, pending: Pending, batched: bool) -> anyhow::Result { + fn write( + &mut self, + pending: Pending, + batched: bool, + anchor: Option<&mut crate::clock::Anchor>, + ) -> anyhow::Result { match self { Stream::H264 { split, @@ -1556,7 +1581,7 @@ impl Stream { reanchor, } => { let reorder = reorder_delay(pending.pts, pending.dts); - let pts = unwrap_pts(unwrap, pending.pts)?; + let pts = unwrap_pts(unwrap, pending.pts, anchor)?; // Each PES is one access unit, so flush to emit it immediately. let mut frames = split.decode(&pending.data, pts)?; frames.extend(split.flush(pts)?); @@ -1578,7 +1603,7 @@ impl Stream { reanchor, } => { let reorder = reorder_delay(pending.pts, pending.dts); - let pts = unwrap_pts(unwrap, pending.pts)?; + let pts = unwrap_pts(unwrap, pending.pts, anchor)?; // Each PES is one access unit, so flush to emit it immediately. let mut frames = split.decode(&pending.data, pts)?; frames.extend(split.flush(pts)?); @@ -1592,10 +1617,10 @@ impl Stream { } Ok(published) } - Stream::Aac(stream) => stream.write(pending, batched), - Stream::Opus(stream) => stream.write(pending), - Stream::Legacy(stream) => stream.write(pending), - Stream::Verbatim(stream) => stream.write(pending), + Stream::Aac(stream) => stream.write(pending, batched, anchor), + Stream::Opus(stream) => stream.write(pending, anchor), + Stream::Legacy(stream) => stream.write(pending, anchor), + Stream::Verbatim(stream) => stream.write(pending, anchor), Stream::Clock | Stream::Ignored => Ok(false), } } @@ -2005,8 +2030,13 @@ struct AacStream { } impl AacStream { - fn write(&mut self, pending: Pending, batched: bool) -> anyhow::Result { - let pes_base = unwrap_pts(&mut self.unwrap, pending.pts)?; + fn write( + &mut self, + pending: Pending, + batched: bool, + anchor: Option<&mut crate::clock::Anchor>, + ) -> anyhow::Result { + let pes_base = unwrap_pts(&mut self.unwrap, pending.pts, anchor)?; // Prepend the partial frame left by the previous PES, if any. let carried = self.tail.len(); @@ -2232,7 +2262,8 @@ impl AacStream { // of it. if !self.tail.is_empty() && self.import.is_some() { self.resync.drain(); - self.write(Pending::empty(), true)?; + // No PTS to translate, so no mapping needed. + self.write(Pending::empty(), true, None)?; } // A partial frame at end of stream isn't emissible; drop it, but leave a trace for // diagnosing truncated captures. @@ -2268,8 +2299,8 @@ struct OpusStream { } impl OpusStream { - fn write(&mut self, pending: Pending) -> anyhow::Result { - let base = unwrap_pts(&mut self.unwrap, pending.pts)?; + fn write(&mut self, pending: Pending, anchor: Option<&mut crate::clock::Anchor>) -> anyhow::Result { + let base = unwrap_pts(&mut self.unwrap, pending.pts, anchor)?; let edge = self.import.live_edge(); let base = base.map(|base| self.reanchor.apply(base, edge)).transpose()?; @@ -2424,9 +2455,9 @@ struct LegacyStream { } impl LegacyStream { - fn write(&mut self, pending: Pending) -> anyhow::Result { + fn write(&mut self, pending: Pending, anchor: Option<&mut crate::clock::Anchor>) -> anyhow::Result { let mut published = false; - let pes_base = unwrap_pts(&mut self.unwrap, pending.pts)?; + let pes_base = unwrap_pts(&mut self.unwrap, pending.pts, anchor)?; // Prepend the partial frame left by the previous PES, if any. let carried = self.tail.len(); @@ -2654,7 +2685,8 @@ impl LegacyStream { // of it. if !self.tail.is_empty() && self.import.is_some() { self.resync.drain(); - self.write(Pending::empty())?; + // No PTS to translate, so no mapping needed. + self.write(Pending::empty(), None)?; } // A partial frame at end of stream isn't emissible verbatim; drop it, but // leave a trace for diagnosing truncated captures. @@ -2722,12 +2754,19 @@ fn advance_pts(pts: Option, samples: u64, sample_rate: u32) -> anyhow /// Convert a raw 90 kHz PTS to a microsecond [`Timestamp`], unwrapping the /// 33-bit field. Returns `None` when the PES carried no PTS. -fn unwrap_pts(unwrap: &mut PtsUnwrap, pts: Option) -> anyhow::Result> { +fn unwrap_pts( + unwrap: &mut PtsUnwrap, + pts: Option, + anchor: Option<&mut crate::clock::Anchor>, +) -> anyhow::Result> { let Some(raw) = pts else { return Ok(None); }; - let extended = unwrap.unwrap(raw); - Ok(Some(Timestamp::from_scale(extended, 90_000)?)) + let extended = Timestamp::from_scale(unwrap.unwrap(raw), 90_000)?; + Ok(Some(match anchor { + Some(anchor) => anchor.translate(&mut unwrap.lane, extended)?, + None => extended, + })) } /// The reorder delay `PTS - DTS` for one PES, as a microsecond [`Timestamp`]. `None` unless @@ -2752,6 +2791,8 @@ fn reorder_delay(pts: Option, dts: Option) -> Option { struct PtsUnwrap { last: Option, offset: u64, + /// This stream's position on the source's broadcast-clock mapping, when publishing live. + lane: crate::clock::Lane, } impl PtsUnwrap { @@ -2775,6 +2816,7 @@ impl PtsUnwrap { /// whatever the source does with its clock next. fn discontinuity(&mut self) { self.last = None; + self.lane.restart(); } } diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 348f2cf926..05ce77c687 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -549,3 +549,110 @@ fn import_handles_unaligned_chunks() { assert_eq!(snapshot.video.renditions.len(), 1); assert_eq!(snapshot.audio.renditions.len(), 1); } + +/// What a TS import published, plus the broadcast clock's reading around the first chunk. +struct LiveImport { + published: std::collections::BTreeMap>, + before: u128, + after: u128, +} + +/// Import `chunks` in order, idling `idle` between them, on a clock that began `ago` earlier. +/// `live` translates onto that clock; otherwise the unwrapped PTS publishes verbatim. +async fn live_import(chunks: &[&[u8]], live: bool, ago: std::time::Duration, idle: std::time::Duration) -> LiveImport { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let config = crate::catalog::Config::default().with_clock(crate::container::test_util::late_clock(ago)); + let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); + let clock = catalog.clock(); + let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve()); + if live { + import = import.live(); + } + + let before = clock.now().as_micros(); + let mut after = before; + for (i, chunk) in chunks.iter().enumerate() { + if i > 0 { + std::thread::sleep(idle); + } + import.decode(chunk).unwrap(); + if i == 0 { + after = clock.now().as_micros(); + } + } + import.finish().unwrap(); + + LiveImport { + published: crate::container::test_util::published(&consumer, &catalog.snapshot()).await, + before, + after, + } +} + +/// A TS feed arriving 30s after the broadcast began publishes on the broadcast clock rather than +/// its own PTS: the stream is live on arrival, and H.264 and AAC keep the one offset their PES +/// headers gave them. +#[tokio::test] +async fn live_import_anchors_a_late_first_frame() { + let data: &[u8] = include_bytes!("test_data/bbb_cbr.ts"); + let ago = std::time::Duration::from_secs(30); + let verbatim = live_import(&[data], false, ago, std::time::Duration::ZERO).await; + let live = live_import(&[data], true, ago, std::time::Duration::ZERO).await; + + let offset = crate::container::test_util::common_offset(&verbatim.published, &live.published); + // The earliest PES anchors at its arrival, which frames muxed ahead of it may precede. + let earliest = verbatim.published.values().map(|t| t[0]).min().unwrap() as i128 + offset; + let skew = std::time::Duration::from_secs(2).as_micros() as i128; + assert!( + live.before as i128 - skew <= earliest && earliest <= live.after as i128, + "the stream is live on arrival: {earliest} not near {}..={}", + live.before, + live.after + ); +} + +/// The same feed played twice, as when an encoder restarts its PTS, continues forward after the +/// real idle gap instead of rewinding. +#[tokio::test] +async fn live_import_restarts_forward_after_idle() { + let data: &[u8] = include_bytes!("test_data/bbb_cbr.ts"); + let once = live_import(&[data], false, std::time::Duration::ZERO, std::time::Duration::ZERO).await; + + // How much source time one pass covers, across every stream. + let starts = once.published.values().map(|t| *t.iter().min().unwrap()); + let ends = once.published.values().map(|t| *t.iter().max().unwrap()); + let span = (ends.max().unwrap() - starts.min().unwrap()) as i128; + + for idle in [std::time::Duration::ZERO, std::time::Duration::from_millis(300)] { + let live = live_import(&[data, data], true, std::time::Duration::from_secs(30), idle).await; + + // Each pass lands on one mapping for every stream: the first frames on the first, the last + // frames on the second. + let offset = |pick: fn(&Vec) -> u128| { + let deltas: Vec = live + .published + .iter() + .map(|(name, t)| pick(t) as i128 - pick(&once.published[name]) as i128) + .collect(); + assert!( + deltas.iter().all(|d| (d - deltas[0]).abs() <= 1_000), + "every stream shares one mapping: {deltas:?}" + ); + deltas[0] + }; + let first = offset(|t| t[0]); + let second = offset(|t| *t.last().unwrap()); + + // The second pass continues after the first plus the real idle gap, not on top of it. + let shift = second - first; + assert!( + shift >= span + idle.as_micros() as i128, + "the restart resumes after the first pass and the idle gap: {shift} < {span} + {idle:?}" + ); + assert!( + shift < span + (idle + std::time::Duration::from_secs(5)).as_micros() as i128, + "the restart is not pushed further: {shift}" + ); + } +} diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 5a1637f7a0..a0a50989da 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -48,6 +48,10 @@ pub enum Error { #[error("unsupported track compression: {0}")] UnsupportedCompression(String), + /// A locally published track's catalog entry points at another broadcast. + #[error("a locally published track can't reference another broadcast")] + ForeignBroadcast, + /// Error parsing or building CMAF moof+mdat fragments. #[error("cmaf: {0}")] Cmaf(#[from] crate::container::fmp4::Error), diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 26692fa745..9d6572eb20 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -51,18 +51,21 @@ //! # } //! ``` +use std::marker::PhantomData; + use serde::Serialize; use serde::de::DeserializeOwned; use hang::catalog::{Compression, JsonConfig, Mode}; -use crate::catalog::Rendition; use crate::catalog::hang::CatalogExt; +use crate::catalog::{IntoRendition, Listing, RenditionConfig}; /// Everything a JSON track declares about itself, beyond its mode and name. /// /// Start from [`default`](Default::default) and chain the setters. The mode is not in here: it is -/// fixed by which producer you create. +/// fixed by which producer you create. To list the track in an application's own catalog section +/// instead of `json`, pass that section's entry (see [`IntoRendition`]). #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct Config { @@ -101,46 +104,86 @@ impl Config { self.delta_ratio = Some(delta_ratio); self } +} + +impl IntoRendition for Config { + type Config = JsonConfig; - /// The catalog entry describing a track published under this config in `mode`. - pub(crate) fn entry(&self, mode: Mode) -> JsonConfig { - let mut entry = JsonConfig::new(mode); + fn into_rendition(self) -> JsonConfig { + // The producer overwrites the mode with the one it publishes in. + let mut entry = JsonConfig::new(Mode::Snapshot); entry.compression = self.compression.then_some(Compression::Deflate); - entry.schema = self.schema.clone(); + entry.schema = self.schema; entry } } +/// Fix `config`'s mode and return whether its frames are compressed. +/// +/// Errors on a compression this build can't write, rather than advertising one the frames don't use, +/// and on a `broadcast` reference, which would point consumers away from the track this publishes. +fn prepare(config: &mut impl AsMut, mode: Mode) -> crate::Result { + let json = config.as_mut(); + if json.broadcast.is_some() { + return Err(crate::Error::ForeignBroadcast); + } + json.mode = mode; + crate::compression(json.compression.as_ref()) +} + +/// The snapshot encoder ratio on a [`Config`] builder, if `config` is one. +/// +/// [`IntoRendition`] only returns the catalog entry, and this ratio is not a catalog field. +/// Downcast keeps it on the existing builder instead of a new trait method. +fn delta_ratio_of(config: &C) -> Option { + (config as &dyn std::any::Any) + .downcast_ref::() + .and_then(|config| config.delta_ratio) +} + /// Publishes a latest-value JSON track, advertised in the catalog for as long as this handle lives. /// /// Every [`update`](Self::update) supersedes the last, so a consumer reads only the newest value. /// For a log where every record survives, use [`Stream`]. pub struct Snapshot { inner: moq_json::snapshot::Producer, - rendition: Rendition, + listing: Listing, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Snapshot { - pub(crate) fn new( + pub(crate) fn new( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, - ) -> crate::Result { + rendition: crate::catalog::Rendition, + config: C, + ) -> crate::Result + where + C: IntoRendition + std::any::Any, + { + // Read before `into_rendition` consumes the builder. Only [`Config`] carries a ratio; + // a custom section entry has none, and the default encoder ratio applies. + let delta_ratio = delta_ratio_of(&config); + let mut config = config.into_rendition(); let mut json = moq_json::snapshot::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Snapshot)? { json.compression = moq_json::Compression::Deflate; } - if let Some(delta_ratio) = config.delta_ratio { + if let Some(delta_ratio) = delta_ratio { json.delta_ratio = delta_ratio; } let inner = moq_json::snapshot::Producer::new(track, json); - rendition.set(config.entry(Mode::Snapshot))?; - Ok(Self { inner, rendition }) + let listing = Listing::new(rendition, config)?; + Ok(Self { + inner, + listing, + _catalog: PhantomData, + }) } /// The track name, which is also the catalog key. pub fn name(&self) -> &str { - self.rendition.name() + self.listing.name() } /// Create a subscriber for the underlying track. @@ -148,9 +191,15 @@ impl Snapshot { self.inner.consume() } + /// A watch-only handle to whether this track has subscribers. + pub fn demand(&self) -> moq_net::track::Demand { + self.inner.demand() + } + /// Publish a new value, superseding the previous one. pub fn update(&mut self, value: &T) -> crate::Result<()> { - Ok(self.inner.update(value)?) + self.inner.update(value)?; + self.listing.record(|| crate::catalog::json_len(value)) } /// Finish the track and retire its catalog entry. @@ -174,25 +223,28 @@ pub struct Stream { /// Cleared when a terminal failure ends the track, which retires the catalog entry with it. An /// entry advertising a track that can no longer accept records only misleads a consumer that /// discovers it afterwards. - rendition: Option>, + listing: Option, + /// Which catalog the entry lives in. The entry's own type is erased by `Listing`. + _catalog: PhantomData E>, } impl Stream { - pub(crate) fn new( + pub(crate) fn new + AsMut>( track: moq_net::track::Producer, - mut rendition: Rendition, - config: &Config, + rendition: crate::catalog::Rendition, + mut config: C, ) -> crate::Result { let mut json = moq_json::stream::Config::default(); - if config.compression { + if prepare(&mut config, Mode::Stream)? { json.compression = moq_json::Compression::Deflate; } let inner = moq_json::stream::Producer::new(track, json); - rendition.set(config.entry(Mode::Stream))?; + let listing = Listing::new(rendition, config)?; Ok(Self { inner, - name: rendition.name().to_string(), - rendition: Some(rendition), + name: listing.name().to_string(), + listing: Some(listing), + _catalog: PhantomData, }) } @@ -209,21 +261,29 @@ impl Stream { self.inner.consume() } + /// A watch-only handle to whether this track has subscribers. + pub fn demand(&self) -> moq_net::track::Demand { + self.inner.demand() + } + /// Append one record to the log. /// - /// Any failure ends the track (see [`moq_json::stream::Producer::append`]) and retires the - /// catalog entry with it. + /// 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<()> { - let Err(err) = self.inner.append(value) else { - return Ok(()); - }; - - // The inner producer has already ended the track. Dropping the rendition 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.rendition = None; + 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()); + } - Err(err.into()) + match &mut self.listing { + Some(listing) => listing.record(|| crate::catalog::json_len(value)), + None => Ok(()), + } } /// Finish the track and retire its catalog entry. @@ -414,6 +474,46 @@ mod test { assert_eq!(drain(consumer), vec![json!({ "live": true })]); } + /// `delta_ratio` is an encoder setting on [`Config`], not a catalog field. A ratio of 0 + /// publishes each value as its own group; a positive ratio keeps the next value in that group. + #[test] + fn a_config_delta_ratio_reaches_the_encoder() { + let (mut broadcast, catalog) = catalog(); + let mut full = catalog + .json_snapshot::(track(&mut broadcast, "full"), Config::default().with_delta_ratio(0)) + .unwrap(); + let mut delta = catalog + .json_snapshot::(track(&mut broadcast, "delta"), Config::default().with_delta_ratio(100)) + .unwrap(); + + let mut full_track = full.consume(); + let mut delta_track = delta.consume(); + for value in [json!({ "n": 1 }), json!({ "n": 2 })] { + full.update(&value).unwrap(); + delta.update(&value).unwrap(); + } + full.finish().unwrap(); + delta.finish().unwrap(); + + // The default subscription budget keeps only the latest group. Ratio 0 rolled a new + // group for the second value, so that group holds one frame. A positive ratio appends + // the second value to the same group. + assert_eq!(ready_groups(&mut full_track), vec![1]); + assert_eq!(ready_groups(&mut delta_track), vec![2]); + } + + fn ready_groups(subscriber: &mut moq_net::track::Subscriber) -> Vec { + let waiter = kio::Waiter::noop(); + let mut counts = Vec::new(); + loop { + match subscriber.poll_recv_group(&waiter) { + Poll::Ready(Ok(Some(group))) => counts.push(group.frame_count()), + Poll::Ready(Ok(None)) | Poll::Pending => return counts, + Poll::Ready(Err(err)) => panic!("group ended in error: {err}"), + } + } + } + #[test] fn the_entry_describes_how_to_read_the_track() { let (mut broadcast, catalog) = catalog(); @@ -486,6 +586,35 @@ mod test { assert_eq!(catalog.snapshot().json.tracks.get("chat"), Some(&existing)); } + /// Writes fill an absent bitrate; one the publisher supplied is left alone. + #[test] + fn writes_fill_an_absent_bitrate() { + let (mut broadcast, catalog) = catalog(); + let mut gps = catalog + .json_stream::(track(&mut broadcast, "gps"), Config::default()) + .unwrap(); + let mut supplied = JsonConfig::new(Mode::Stream); + supplied.bitrate = Some(4_200); + let mut status = catalog + .json_snapshot::(track(&mut broadcast, "status"), supplied) + .unwrap(); + + // 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(); + } + + assert_eq!(entry(&catalog, "gps").bitrate, Some(100_000)); + assert_eq!(entry(&catalog, "status").bitrate, Some(4_200)); + assert_eq!( + entry(&catalog, "gps").jitter, + None, + "write spacing is not a flush delay" + ); + } + /// 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/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 7f29dd53b8..7fc73f3d32 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.4](https://github.com/moq-dev/moq/compare/moq-net-v0.3.3...moq-net-v0.3.4) - 2026-09-25 + +### Fixed + +- *(net)* skip a stale warm cache on an IETF rejoin ([#4150](https://github.com/moq-dev/moq/pull/4150)) + +## [0.3.3](https://github.com/moq-dev/moq/compare/moq-net-v0.3.2...moq-net-v0.3.3) - 2026-09-25 + +### Fixed + +- *(net)* end an IETF subscription from its PUBLISH_DONE ([#4083](https://github.com/moq-dev/moq/pull/4083)) +- *(net)* use the registered IETF priority property and TRACK_STATUS/FETCH refusal codes ([#3937](https://github.com/moq-dev/moq/pull/3937)) + ## [0.3.2](https://github.com/moq-dev/moq/compare/moq-net-v0.3.1...moq-net-v0.3.2) - 2026-09-25 ### Added diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 66b697784e..1dade572a1 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.2" +version = "0.3.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index ee412b1cfe..ce4b209d28 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -4,7 +4,7 @@ use crate::runtime::Timers; use crate::time::{Clock, Instant}; use crate::{ ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_19, ALPN_20, ALPN_21, ALPN_22, ALPN_LITE, ALPN_LITE_03, - ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07, Consume, Error, NEGOTIATED, Session, Version, Versions, + ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, Error, NEGOTIATED, Session, Version, Versions, coding::{self, Decode, Encode, Stream}, ietf, lite, setup, stats, }; @@ -216,7 +216,7 @@ impl Client { { let runtime = Clock::new(now); let version = match session.protocol() { - Some(ALPN_LITE_07) => lite::Version::Lite07, + Some(ALPN_LITE_07_WIP) => lite::Version::Lite07, Some(ALPN_LITE_06) => lite::Version::Lite06, Some(ALPN_LITE_05) => lite::Version::Lite05, Some(ALPN_LITE_04) => lite::Version::Lite04, @@ -301,9 +301,9 @@ impl Client { .ok_or(Error::Version)?; (v, v.into()) } - Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07)) => { + Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07_WIP)) => { let version = match alpn { - ALPN_LITE_07 => lite::Version::Lite07, + ALPN_LITE_07_WIP => lite::Version::Lite07, ALPN_LITE_06 => lite::Version::Lite06, _ => lite::Version::Lite05, }; diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index 36bda1ca33..787496a600 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -444,7 +444,9 @@ impl Message for PublishOk { }) } _ => { + // EXPIRES is ignored, as in SUBSCRIBE_OK. decode_params!(r, version, + 0x08 => _expires: Option, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, 0x21 => filter: Option, @@ -921,6 +923,16 @@ mod tests { assert_eq!(decoded.subscriber_priority, 128); } + /// Draft-18 lets PUBLISH_OK carry EXPIRES; it is ignored rather than rejected. + #[test] + fn test_publish_ok_ignores_expires() { + let bytes = [0x04, 0x01, 0x08, 0x05]; + + let decoded: PublishOk = decode_message(&bytes, Version::Draft16).unwrap(); + assert_eq!(decoded.request_id, Some(RequestId(4))); + assert!(decoded.forward); + } + #[test] fn test_publish_done_preserves_unknown_status() { const UNKNOWN: u64 = 0xface; diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 2af6a5e0e3..6f9a366990 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -3,6 +3,10 @@ use crate::{frame, group, origin, track}; use std::{ collections::HashMap, ops::Bound, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, task::{Poll, ready}, time::Duration, }; @@ -587,35 +591,55 @@ where // Run the track, cancelling on reader close (Unsubscribe or stream close). // The fill (when one was requested) runs alongside on its own fetch stream; // its failures reset that stream and never touch the subscription. - let res = { - let mut track_serve = - TrackServe::new(self.session.clone(), track, request_id, self.version, range, timescale); + let mut track_serve = + TrackServe::new(self.session.clone(), track, request_id, self.version, range, timescale); + let served = { let serve = async { match fill { Some((fill, cache, timescale)) => { let fill = self.run_fill(request_id, priority, fill, cache, timescale); let track = kio::wait(|waiter| track_serve.poll(waiter)); - let (res, ()) = futures::join!(track, fill); - res + let (res, filled) = futures::join!(track, fill); + (res, filled) } - None => kio::wait(|waiter| track_serve.poll(waiter)).await, + None => (kio::wait(|waiter| track_serve.poll(waiter)).await, false), } }; let mut serve = std::pin::pin!(serve); let mut closed_session = self.session.clone(); kio::wait(|waiter| { - if let Poll::Ready(res) = waiter.poll_future(serve.as_mut()) { - return Poll::Ready(res); + if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { + return Poll::Ready(Some(served)); } let mut cx = waiter.context(); if stream.reader.poll_closed(&mut cx).is_ready() || closed_session.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Ok(())); + return Poll::Ready(None); } Poll::Pending }) .await }; + // Every data stream this subscription opened is closed by now, which PUBLISH_DONE + // requires, so the count it reports is final. + let completed = served.is_some(); + let (res, filled) = served.unwrap_or((Ok(()), false)); + let mut streams = track_serve.opened() + u64::from(filled); + + // Draft-14 on carries no end location in PUBLISH_DONE: an END_OF_TRACK object is + // what tells the subscriber where the track ended. A cancelled subscription is + // owed nothing more. + if completed + && res.is_ok() + && let Some(end) = track_serve.end() + { + match track_serve.write_end_of_track(end, priority).await { + Ok(()) => streams += 1, + // A failure only costs the subscriber the early boundary. + Err(err) => tracing::debug!(%err, id = %request_id, "end of track failed"), + } + } + // Send PublishDone let (status, reason) = match &res { Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), @@ -630,7 +654,7 @@ where _ => None, }, status_code: status.code(self.version), - stream_count: 0, + stream_count: streams, reason_phrase: reason.into(), }) .await; @@ -716,6 +740,8 @@ where /// A fill is a promise once requested. An empty range opens no stream, but a range we /// cannot serve still opens one and resets it right after the FETCH_HEADER, the /// draft's fill-failure signal. Nothing here touches the subscription either way. + /// + /// Returns whether it opened a stream, which PUBLISH_DONE's Stream Count includes. async fn run_fill( &self, request_id: RequestId, @@ -723,9 +749,9 @@ where fill: FillServe, track: track::Consumer, timescale: Option, - ) { + ) -> bool { if matches!(fill, FillServe::Empty) { - return; + return false; } let mut session = self.session.clone(); @@ -733,7 +759,7 @@ where Ok(stream) => stream, Err(err) => { tracing::debug!(err = %Error::from_transport(err), fill = %request_id, "fill stream failed to open"); - return; + return false; } }; let mut stream = Writer::new(stream, self.version); @@ -775,6 +801,7 @@ where stream.abort(&err); } } + true } /// Write one group's frames in the negotiated draft's FETCH object layout. @@ -1929,6 +1956,10 @@ struct TrackServe { children: kio::Tasks>, /// The track finished: the in-flight group machines drain, then FIN. draining: bool, + /// Group streams opened, shared with the group machines. + opened: Arc, + /// The track's exclusive end, once its groups ran out because it finished. + end: Option, } impl TrackServe { @@ -1959,9 +1990,51 @@ impl TrackServe { timescale, children: kio::Tasks::new(), draining: false, + opened: Default::default(), + end: None, } } + /// Group streams opened so far. + fn opened(&self) -> u64 { + self.opened.load(Ordering::Relaxed) + } + + /// Where the track ends, when the subscription ran to that end rather than stopping at + /// its own range first. + fn end(&self) -> Option { + let end = self.end?; + let reached = self.range.end.is_none_or(|last| last.group.saturating_add(1) >= end); + reached.then_some(end) + } + + /// Mark the track's end with an END_OF_TRACK object on its own stream, at object 0 of + /// the group that will never exist. + /// + /// The last group's stream has usually finished before the track ends, so the marker + /// cannot ride on it. The stream counts toward PUBLISH_DONE once it is open. + async fn write_end_of_track(&mut self, end: u64, priority: u8) -> Result<(), Error> { + let mut stream = std::future::poll_fn(|cx| self.session.poll_open_uni(cx)) + .await + .map_err(Error::from_transport)?; + stream.set_priority(priority); + + let mut writer = Writer::new(stream, self.version); + writer.buffer(&ietf::GroupHeader { + track_alias: self.request_id.0, + group_id: end, + sub_group_id: 0, + publisher_priority: super::priority::to_wire(self.track.info().priority), + flags: ietf::GroupFlags::default(), + })?; + // Object ID delta 0, then an empty object whose status is END_OF_TRACK. + writer.buffer(&0u64)?; + writer.buffer(&0u64)?; + writer.encode(&END_OF_TRACK).await?; + // PUBLISH_DONE follows once this closes, like every other data stream. + writer.close().await + } + fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { if self.draining { return self.children.poll(waiter).map(Ok); @@ -2006,18 +2079,24 @@ impl TrackServe { }, }; - self.children.push(GroupServe::new( - self.session.clone(), - msg, - self.track.subscription().priority, - group, - self.timescale, - self.version, - slice, - )); + self.children.push( + GroupServe::new( + self.session.clone(), + msg, + self.track.subscription().priority, + group, + self.timescale, + self.version, + slice, + ) + .counted(self.opened.clone()), + ); } Poll::Ready(Ok(None)) => { self.draining = true; + if let Poll::Ready(Ok(end)) = self.track.poll_finished(waiter) { + self.end = Some(end); + } return self.children.poll(waiter).map(Ok); } Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), @@ -2034,6 +2113,8 @@ impl TrackServe { /// subgroup format. struct GroupServe { session: S, + /// The subscription's count of opened streams, bumped once this one opens. + opened: Arc, msg: ietf::GroupHeader, priority: u8, group: group::Consumer, @@ -2092,6 +2173,7 @@ impl GroupServe { let object_delta = group.index(); Self { session, + opened: Default::default(), msg, priority, group, @@ -2102,6 +2184,12 @@ impl GroupServe { } } + /// Count this group's stream into `opened` once it opens. + fn counted(mut self, opened: Arc) -> Self { + self.opened = opened; + self + } + fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { let mut cx = waiter.context(); loop { @@ -2118,6 +2206,7 @@ impl GroupServe { return Poll::Ready(Err(Error::from_transport(err))); } }; + self.opened.fetch_add(1, Ordering::Relaxed); let mut stream = stream; stream.set_priority(self.priority); @@ -2274,6 +2363,9 @@ impl GroupServe { } } +/// Object status: no object at or past this location exists. +const END_OF_TRACK: u64 = 0x4; + /// Buffer one object's header and prefix: the id delta, optional extension /// headers carrying the timestamp, the size, and (for an empty object) the status. fn buffer_object( diff --git a/rs/moq-net/src/ietf/request.rs b/rs/moq-net/src/ietf/request.rs index 12b1916dbc..1b783556d8 100644 --- a/rs/moq-net/src/ietf/request.rs +++ b/rs/moq-net/src/ietf/request.rs @@ -108,7 +108,10 @@ impl Message for RequestOk { } else { None }; - decode_params!(r, version,); + // A REQUEST_UPDATE_OK may refresh EXPIRES, which is ignored like SUBSCRIBE_OK's. + decode_params!(r, version, + 0x08 => _expires: Option, + ); Ok(Self { request_id }) } } @@ -193,6 +196,15 @@ mod tests { assert_eq!(decoded.request_id, Some(RequestId(42))); } + /// A REQUEST_UPDATE_OK may refresh EXPIRES; it is ignored rather than rejected. + #[test] + fn test_request_ok_ignores_expires() { + let bytes = [0x07, 0x01, 0x08, 0x05]; + + let decoded: RequestOk = decode_message(&bytes, Version::Draft16).unwrap(); + assert_eq!(decoded.request_id, Some(RequestId(7))); + } + #[test] fn test_request_error_round_trip() { let msg = RequestError { diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index f19f67f9c7..07facd099e 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -269,10 +269,9 @@ impl Message for SubscribeOk { match version { Version::Draft14 => { - let expires = u64::decode(r, version)?; - if expires != 0 { - return Err(DecodeError::Unsupported); - } + // EXPIRES is when the publisher expects to end the subscription. That end + // arrives as PUBLISH_DONE regardless, so there is nothing to act on. + let _expires = u64::decode(r, version)?; properties.group_order = Some(GroupOrder::decode(r, version)?.any_to_descending()); @@ -287,8 +286,9 @@ impl Message for SubscribeOk { // peer that still sends it doesn't have its session torn down over a hint. // LARGEST_OBJECT is required on every draft once the track has content, so // rejecting it would tear down a session over a parameter compliant - // publishers must send. + // publishers must send. EXPIRES is ignored, as on draft-14. decode_params!(r, version, + 0x08 => _expires: Option, 0x09 => largest: Option, 0x22 => group_order: Option, ); @@ -924,18 +924,45 @@ mod tests { } #[test] - fn test_subscribe_ok_rejects_non_zero_expires() { + fn test_subscribe_ok_ignores_expires_v14() { #[rustfmt::skip] - let invalid_bytes = vec![ - 0x01, // subscribe_id - 0x05, // INVALID: expires = 5 + let bytes = [ + 0x01, // request_id + 0x00, // track_alias + 0x05, // expires = 5 0x02, // group_order 0x00, // content_exists 0x00, // num_params ]; - let result: Result = decode_message(&invalid_bytes, Version::Draft14); - assert!(result.is_err()); + let decoded: SubscribeOk = decode_message(&bytes, Version::Draft14).unwrap(); + assert_eq!(decoded.request_id, Some(RequestId(1))); + } + + /// The SUBSCRIBE_OK aiomoqt 0.11.0 sends: EXPIRES = 0 and nothing else (#4172). + #[test] + fn test_subscribe_ok_ignores_expires_aiomoqt() { + let bytes = [0x03, 0x00, 0x01, 0x08, 0x00]; + + let decoded: SubscribeOk = decode_message(&bytes, Version::Draft16).unwrap(); + assert_eq!(decoded.request_id, Some(RequestId(3))); + assert_eq!(decoded.track_alias, 0); + assert!(decoded.largest.is_none()); + } + + /// The SUBSCRIBE_OK a libquicr relay sends: EXPIRES = 0 followed by five track + /// properties (#4172). + #[test] + fn test_subscribe_ok_ignores_expires_libquicr() { + let bytes = [ + 0x02, 0xde, 0x53, 0x15, 0xbf, 0xd6, 0x39, 0x31, 0x88, 0x01, 0x08, 0x00, 0x02, 0x00, 0x02, 0x00, 0x0a, 0x01, + 0x14, 0x01, 0x0e, 0x01, + ]; + + let decoded: SubscribeOk = decode_message(&bytes, Version::Draft16).unwrap(); + assert_eq!(decoded.request_id, Some(RequestId(2))); + assert_eq!(decoded.properties.priority, Some(1)); + assert_eq!(decoded.properties.group_order, Some(GroupOrder::Ascending)); } #[test] diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d34dd4b05b..13f5f5c2b0 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -7,7 +7,7 @@ use std::{ use crate::{ Error, Path, PathOwned, SessionError, Timescale, broadcast, - coding::{Reader, Stream}, + coding::{Decode, DecodeError, Reader, Stream}, frame, group, ietf::{self, Control, FetchType, Filter, GroupOrder, RequestId}, origin, track, @@ -15,6 +15,7 @@ use crate::{ }; use super::{Message, Version, cluster, error::request, peer}; +use crate::tail::{self, Settle, Tail}; use kio::Lock; @@ -322,6 +323,9 @@ struct TrackState { // A pre-draft-20 joining FETCH, which reuses the fill rendezvous. joining: Option, + + // The data streams read so far, which PUBLISH_DONE's Stream Count is checked against. + tail: kio::Producer, } impl TrackState { @@ -348,6 +352,21 @@ impl TrackState { largest: None, fetch_id: None, joining, + tail: Default::default(), + } + } +} + +/// Counts a data stream toward its subscription's [`Tail`] once its handler is done with it. +/// +/// Counting at the end rather than at the header means a Stream Count that is reached never +/// races the stream's own work, such as the END_OF_TRACK that declares where the track ends. +struct Counted(kio::Producer); + +impl Drop for Counted { + fn drop(&mut self) { + if let Ok(mut tail) = self.0.write() { + tail.stream(); } } } @@ -1564,6 +1583,8 @@ where } let subscription = request.subscription(); + // A live join delivers nothing below the group SUBSCRIBE_OK names as Largest. + let live = subscription.as_ref().and_then(|s| s.start).is_none(); let join = match subscribe_join( subscription.as_ref().and_then(|s| s.start), subscription.as_ref().and_then(|s| s.end), @@ -1700,7 +1721,16 @@ where .with_timescale(Timescale::MICRO) .with_max_age(self.origin.default_max_age()) .with_priority(super::priority::from_wire(priority.unwrap_or(128))); + // Declared before the track is released to readers, so a warm cache waiting on + // this copy judges itself against where the live feed actually starts. + let request = match live { + true => request.resolving_start(), + false => request, + }; let mut track = request.accept(info); + if live { + let _ = track.start_at(largest.map(|largest| largest.group)); + } let mut fetching: Option> = None; { let mut state = self.state.lock(); @@ -1742,7 +1772,7 @@ where // does not disturb subscriptions already in flight. enum End { Unused, - Done(Result<(), Error>), + Done(Result), } let mut fetch_done = fetching.is_none(); @@ -1773,8 +1803,34 @@ where }, End::Done(res) => { match res { - Ok(()) => { + Ok(count) => { tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe complete"); + // The publisher sends PUBLISH_DONE once every data stream it opened + // is closed, but QUIC does not order them, so some can still be on + // their way. Wait until Stream Count of them are read, or a bounded + // grace for any reset before its header (the draft says to use a + // timeout). The count is a hint: a published peer sends 0, which + // waits out the grace. + let tail = self + .state + .lock() + .subscribes + .get(&request_id) + .map(|held| held.tail.consume()); + if let Some(tail) = tail { + let mut settle = Settle::new(&self.runtime, tail, tail::GRACE); + kio::wait(|waiter| { + if !fetch_done + && let Some(fut) = fetching.as_mut() + && waiter.poll_future(fut.as_mut()).is_ready() + { + fetch_done = true; + } + settle.poll(waiter, |tail| count > 0 && tail.streams() >= count) + }) + .await; + } + // A no-op once an END_OF_TRACK declared the end. let _ = track.finish(); } Err(err) => { @@ -1801,11 +1857,12 @@ where } } - /// Read the PUBLISH_DONE that ends an Established subscription, as the end it reports. + /// Read the PUBLISH_DONE that ends an Established subscription, as the end it reports + /// and, for a clean end, its Stream Count. /// /// The publisher must send it before its FIN (draft-19 section 3.3.2), so a FIN /// without one is a failed request, not a clean end. - async fn read_publish_done(reader: &mut Reader, version: Version) -> Result<(), Error> { + async fn read_publish_done(reader: &mut Reader, version: Version) -> Result { match reader.decode_maybe::().await? { Some(ietf::PublishDone::ID) => {} Some(_) => return Err(Error::UnexpectedMessage), @@ -1813,7 +1870,8 @@ where } let msg: ietf::PublishDone = reader.decode().await?; tracing::debug!(message = ?msg, "received publish done"); - msg.end(version) + msg.end(version)?; + Ok(msg.stream_count) } /// Tell the publisher to stop serving a subscription we are walking away from. @@ -2088,13 +2146,16 @@ where } }; - let (track, timescale, fill) = { + let (mut track, timescale, fill, _counted) = { let state = self.state.lock(); let track = state.subscribes.get(&request_id).ok_or(Error::NotFound)?; ( track.producer.clone().ok_or(Error::NotFound)?, track.timescale, track.fill.clone(), + // Every data stream counts toward PUBLISH_DONE's Stream Count, even one + // dropped below. + Counted(track.tail.clone()), ) }; @@ -2129,9 +2190,9 @@ where // The peek inside blocks until the publisher produces the group's first object, so // race it against the subscription going away the same way the group read below is. // Otherwise dropping the local subscriber cannot end this handler. - let (producer, start) = { + let opened = { let mut opening = track.clone(); - let mut open = std::pin::pin!(self.open_group(stream, &mut opening, &fill, group.group_id)); + let mut open = std::pin::pin!(self.open_group(stream, &mut opening, &fill, &group)); kio::wait(|waiter| { if let Poll::Ready(err) = track.poll_closed(waiter) { return Poll::Ready(Err(err)); @@ -2140,6 +2201,11 @@ where }) .await? }; + let (producer, start) = match opened { + Opened::Group(producer, start) => (producer, start), + // No object at or past object 0 of this group exists, so neither does the group. + Opened::EndOfTrack => return end_track(&mut track, group.group_id), + }; // Guarded: this handler can be dropped at any await below, and a group producer // that dies without a terminal leaves its consumer waiting on nothing. @@ -2168,44 +2234,123 @@ where tracing::debug!(%err, group = %producer.sequence, "group error"); let _ = producer.abort(err); } - _ => { + Ok(Ended::Group) => { let _ = producer.finish(); } + // No object past this group's last one exists, so the track ends after it. + Ok(Ended::Track) => { + let _ = producer.finish(); + return end_track(&mut track, group.group_id.saturating_add(1)); + } } Ok(()) } } +/// Mark where the track ends, as an END_OF_TRACK object said. +/// +/// Draft-14 on carries no end location in PUBLISH_DONE, so this is what lets a subscriber +/// learn the end before the live edge reaches it. A boundary at or below a group already +/// received is the publisher breaking its own end, which no later group can repair. A +/// marker that lands after the subscription already ended (its grace expired) changes +/// nothing. +fn end_track(track: &mut track::Producer, end: u64) -> Result<(), Error> { + if track.final_sequence().is_some() { + return Ok(()); + } + if let Err(err) = track.finish_at(end) { + tracing::warn!(%err, end, "invalid END_OF_TRACK"); + let _ = track.clone().abort(Error::ProtocolViolation); + return Err(Error::ProtocolViolation); + } + Ok(()) +} + +/// How [`Subscriber::open_group`] resolved a subgroup stream. +enum Opened { + /// The group producer the stream writes into, and the Object ID it starts at. + Group(group::Producer, u64), + /// The stream's first object is an END_OF_TRACK at object 0: the group does not exist. + EndOfTrack, +} + +/// How a subgroup stream ended cleanly. +#[derive(Debug, PartialEq, Eq)] +enum Ended { + /// The stream finished, or carried an explicit end of group. + Group, + /// It carried an END_OF_TRACK after the group's last object. + Track, +} + +/// Object status: no object at or past this location exists (every implemented draft). +const END_OF_TRACK: u64 = 0x4; + +/// The start of a subgroup stream's first object, peeked before its group is created. +#[derive(Debug, Clone, Copy)] +struct FirstObject { + /// The Object ID, which the first object's delta is. + id: u64, + end_of_track: bool, +} + +/// [`FirstObject`] for a stream whose objects carry extensions, or don't. +#[derive(Debug)] +struct PeekFirst(FirstObject); + +impl Decode for PeekFirst { + fn decode(buf: &mut B, version: Version) -> Result { + let id = u64::decode(buf, version)?; + if EXTENSIONS { + let size = usize::decode(buf, version)?; + if buf.remaining() < size { + return Err(DecodeError::Short); + } + buf.advance(size); + } + let size = u64::decode(buf, version)?; + let end_of_track = size == 0 && u64::decode(buf, version)? == END_OF_TRACK; + Ok(Self(FirstObject { id, end_of_track })) + } +} + impl Subscriber where S: crate::transport::poll::Boxable, { /// The group producer this subgroup stream writes into, and the Object ID it starts at. /// - /// Normally the stream starts the group. While a fill is outstanding it may instead be - /// the tail of the group the fill fetch stream began, which the first Object ID decides, - /// so the stream is peeked before any producer exists. A subscription with no fill skips - /// the peek: creating the group up front is what it has always done, and waiting for the - /// first object would hold the group back for as long as the publisher takes to produce - /// it. + /// The first object is peeked before any producer exists, since an END_OF_TRACK at + /// object 0 means the group does not exist at all. Normally the stream then starts the + /// group. While a fill is outstanding it may instead be the tail of the group the fill + /// fetch stream began, which the first Object ID decides. async fn open_group( &self, stream: &mut Reader, track: &mut track::Producer, fill: &kio::Producer, - sequence: u64, - ) -> Result<(group::Producer, u64), Error> { + header: &ietf::GroupHeader, + ) -> Result { + let sequence = header.group_id; // Stats (groups/frames/bytes) are counted in the model as the group is written, // through the tagged `track::Producer`. let create = |track: &mut track::Producer| track.create_group(group::Info { sequence }); + let first = match header.flags.has_extensions { + true => stream.decode_peek_maybe::>().await?.map(|peek| peek.0), + false => stream.decode_peek_maybe::>().await?.map(|peek| peek.0), + }; + if first.is_some_and(|first| first.id == 0 && first.end_of_track) { + return Ok(Opened::EndOfTrack); + } + if !fill.read().outstanding() { - return Ok((create(track)?, 0)); + return Ok(Opened::Group(create(track)?, 0)); } // The first object's ID delta is its absolute Object ID (see `next_object_id`). - match stream.decode_peek_maybe::().await? { + match first.map(|first| first.id) { // A group delivered from its start stands alone, unless the fill already // headed this very sequence: the publisher then served those objects twice, // and the model has one producer per group. Publish the head as the prefix it @@ -2220,13 +2365,13 @@ where return Err(Error::Unsupported); } - Ok((create(track)?, 0)) + Ok(Opened::Group(create(track)?, 0)) } // A group starting partway through is the tail of one the fill began, and // without that head it has a hole at the front. Some(start) => match self.claim_fill(fill, track, sequence, Some(start)).await? { - Some(producer) => Ok((producer, start)), + Some(producer) => Ok(Opened::Group(producer, start)), None => { tracing::warn!(sequence, start, "no fill to stitch a mid-group stream onto"); Err(Error::Unsupported) @@ -2236,8 +2381,8 @@ where // A stream that ends without an object: the group is over and had nothing // outside the fill's range, so the head it delivered is the whole group. None => match self.claim_fill(fill, track, sequence, None).await? { - Some(producer) => Ok((producer, 0)), - None => Ok((create(track)?, 0)), + Some(producer) => Ok(Opened::Group(producer, 0)), + None => Ok(Opened::Group(create(track)?, 0)), }, } } @@ -2308,8 +2453,8 @@ enum IngestPhase { Status { timestamp: Option }, /// Streaming the object payload. Payload { frame: frame::ProducerOwned }, - /// An explicit end-of-group status arrived. - Finished, + /// An explicit end-of-group or end-of-track status arrived. + Finished(Ended), } impl GroupIngest { @@ -2350,17 +2495,17 @@ where let _: u64 = stream.decode().await?; let header: ietf::FetchHeader = stream.decode().await?; - let (subscribe_id, fill, joining, largest) = { + let (subscribe_id, fill, joining, largest, _counted) = { let state = self.state.lock(); // A draft-20 fill is named by the SUBSCRIBE's request id. A pre-draft-20 joining // FETCH has its own id, which `fetches` maps back to that subscription. - let subscribe_id = state - .fetches - .get(&header.request_id) - .copied() - .unwrap_or(header.request_id); + let joined = state.fetches.get(&header.request_id).copied(); + let subscribe_id = joined.unwrap_or(header.request_id); let track = state.subscribes.get(&subscribe_id).ok_or(Error::NotFound)?; - (subscribe_id, track.fill.clone(), track.joining, track.largest) + // A fill is one of the subscription's own data streams, so PUBLISH_DONE counts + // it. A joining FETCH is a request of its own. + let counted = joined.is_none().then(|| Counted(track.tail.clone())); + (subscribe_id, track.fill.clone(), track.joining, track.largest, counted) }; // SUBSCRIBE_OK declares the units these object timestamps are in, and this stream can @@ -2729,21 +2874,21 @@ fn settle_join_live(fill: &kio::Producer) { } impl GroupIngest { - /// `Ready(Ok(()))` once the stream FINs on an object boundary (or an explicit - /// end-of-group status arrives). The caller finishes or aborts the group; an - /// object cut short mid-payload was already aborted here with the reason. + /// `Ready(Ok(_))` once the stream FINs on an object boundary, or an explicit + /// end-of-group or end-of-track status arrives. The caller finishes or aborts the + /// group; an object cut short mid-payload was already aborted here with the reason. fn poll( &mut self, reader: &mut Reader, group: &mut group::Producer, waiter: &kio::Waiter, - ) -> Poll> { + ) -> Poll> { let mut cx = waiter.context(); loop { match &mut self.phase { IngestPhase::Delta => { let Some(id_delta) = ready!(reader.poll_decode_maybe::(&mut cx))? else { - return Poll::Ready(Ok(())); + return Poll::Ready(Ok(Ended::Group)); }; self.prior_object = Some(next_object_id(self.prior_object, id_delta, self.start)?); self.phase = match self.has_extensions { @@ -2788,7 +2933,11 @@ impl GroupIngest { frame.finish()?; self.phase = IngestPhase::Delta; } else if status == 3 && !self.has_end { - self.phase = IngestPhase::Finished; + self.phase = IngestPhase::Finished(Ended::Group); + } else if status == END_OF_TRACK { + // Defined on every implemented draft, whether or not the header marks + // the group's end. + self.phase = IngestPhase::Finished(Ended::Track); } else { return Poll::Ready(Err(Error::Unsupported)); } @@ -2809,7 +2958,10 @@ impl GroupIngest { } } } - IngestPhase::Finished => return Poll::Ready(Ok(())), + IngestPhase::Finished(ended) => { + let ended = std::mem::replace(ended, Ended::Group); + return Poll::Ready(Ok(ended)); + } } } } @@ -5727,6 +5879,42 @@ mod stitch_tests { assert!(matches!(*h.fill.read(), Fill::Done), "the head was claimed"); } + /// Append an END_OF_TRACK object: delta 0, an empty payload, then its status. + fn end_of_track(mut stream: Vec) -> Vec { + for value in [0u64, 0, END_OF_TRACK] { + value.encode(&mut stream, VERSION).unwrap(); + } + stream + } + + /// END_OF_TRACK after a group's last object ends the track right after that group. + #[tokio::test] + async fn an_end_of_track_after_a_group_ends_the_track_after_it() { + let h = Harness::new(Fill::Done, vec![end_of_track(tail_stream(SEQUENCE, 0, &[b"last"]))]); + let mut consumer = h.track.subscribe(None); + let mut stream = h.stream().await; + + h.subscriber.clone().recv_group(&mut stream).await.unwrap(); + assert_eq!(h.track.final_sequence(), Some(SEQUENCE + 1)); + + let mut group = consumer.recv_group().await.unwrap().expect("the group arrives"); + assert_eq!(group.read_frame().await.unwrap().unwrap().payload.as_ref(), b"last"); + assert!(group.read_frame().await.unwrap().is_none(), "the group is finished"); + assert!(consumer.recv_group().await.unwrap().is_none(), "then the track ends"); + } + + /// END_OF_TRACK at object 0 says the group does not exist, so the track ends before it + /// and no group is created for it. + #[tokio::test] + async fn an_end_of_track_at_object_zero_creates_no_group() { + let h = Harness::new(Fill::Done, vec![end_of_track(tail_stream(SEQUENCE, 0, &[]))]); + let mut stream = h.stream().await; + + h.subscriber.clone().recv_group(&mut stream).await.unwrap(); + assert_eq!(h.track.final_sequence(), Some(SEQUENCE)); + assert_eq!(h.track.latest(), None, "no group was created"); + } + /// The group ended exactly where we joined it, so the subscription's stream carries no /// objects at all. That still ends the group, which is what publishes the head. #[tokio::test] diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index bb07231e07..0ec7ba1348 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -86,6 +86,7 @@ mod model; pub mod path; mod recv; mod setup; +mod tail; mod util; mod version; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index ca7bf47322..75fb860bab 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -3,7 +3,10 @@ use crate::{SessionError, announce, frame, group, origin, track}; use std::{ collections::HashMap, ops::Bound, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, task::{Poll, ready}, time::Duration, }; @@ -1153,6 +1156,7 @@ impl SubscribeServe { track_priority_seen: msg.priority, version: self.shared.version, timescale, + opens: Default::default(), }; let run = TrackRun::new(sub, track, Bounds::from(&msg), track_priority_tx); @@ -2136,6 +2140,18 @@ struct Subscription { /// Negotiated timestamp scale for this track. `Some(_)` on lite-05+ after /// TRACK_INFO; used to validate per-frame timestamps before encoding. timescale: Option, + /// The group streams this subscription opened, shared by every group it serves. + opens: Arc, +} + +/// Counts a subscription's group streams for lite-07's SUBSCRIBE_END. +/// +/// A group is pending from the moment it is queued until its stream opens, or it gives +/// up first (expired, or the open failed) and is never counted. +#[derive(Default)] +struct Opens { + pending: AtomicU64, + opened: AtomicU64, } impl Subscription { @@ -2218,6 +2234,9 @@ struct TrackRun { emit_range: bool, start_sent: bool, 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. + count_streams: bool, // Serve datagrams off this same subscriber, but only on lite-05+ over a // datagram-capable transport (qmux/WebSocket/TCP/UDS report size 0). No group // fallback: otherwise off. @@ -2239,6 +2258,7 @@ impl TrackRun { track.end_at(bounds.end_group.map_or(Bound::Unbounded, Bound::Included)); let emit_range = ctx.version.has_track_stream(); + let count_streams = ctx.version.has_stream_count(); let datagrams = ctx.version.has_datagrams() && ctx.session.max_datagram_size() > 0; Self { @@ -2250,6 +2270,7 @@ impl TrackRun { emit_range, start_sent: false, end_sent: false, + count_streams, datagrams, children: kio::Tasks::new(), } @@ -2302,7 +2323,7 @@ impl TrackRun { // 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 // group is ready (including while groups are parked above the cap). - let emit_boundary = self.emit_range && !self.end_sent; + let emit_boundary = self.emit_range && !self.end_sent && !self.count_streams; if let Poll::Ready(res) = poll_recv_next(&mut self.track, self.datagrams, emit_boundary, waiter) { match res? { Recv::Group(mut group) => { @@ -2354,7 +2375,22 @@ impl TrackRun { self.end_sent = true; stream .writer - .buffer(&lite::SubscribeResponse::End(lite::SubscribeEnd { group }))?; + .buffer(&lite::SubscribeResponse::End(lite::SubscribeEnd { group, streams: 0 }))?; + } + Recv::Finished if self.count_streams && !self.end_sent => { + // The count is final only once no served group is still waiting to + // open its stream. A group that gives up first is never counted, so + // the subscriber is not left waiting for it. The child's wake + // re-polls this loop. + if self.ctx.opens.pending.load(Ordering::Relaxed) > 0 { + return Poll::Pending; + } + let group = ready!(self.track.poll_finished(waiter))?; + let streams = self.ctx.opens.opened.load(Ordering::Relaxed); + self.end_sent = true; + stream + .writer + .buffer(&lite::SubscribeResponse::End(lite::SubscribeEnd { group, streams }))?; } Recv::Finished => return Poll::Ready(Ok(TrackEnd::Finished)), } @@ -2412,6 +2448,7 @@ impl GroupServe { priority: PriorityHandle, group: group::Consumer, ) -> Self { + ctx.opens.pending.fetch_add(1, Ordering::Relaxed); Self { ctx, priority, @@ -2423,6 +2460,14 @@ impl GroupServe { } } + /// Leave [`GroupState::Open`], counting the stream if it opened. + fn settle_open(&mut self, opened: bool) { + self.ctx.opens.pending.fetch_sub(1, Ordering::Relaxed); + if opened { + self.ctx.opens.opened.fetch_add(1, Ordering::Relaxed); + } + } + /// Serve the group, aborting the stream with the real reason (Old, Lagged, /// Evicted, ...) on failure so the subscriber can tell a truncated group from /// a routine cancel. Without this the Writer's Drop fallback would report @@ -2432,6 +2477,7 @@ impl GroupServe { match &mut self.state { GroupState::Open => { if self.group.poll_expired(waiter) { + self.settle_open(false); self.state = GroupState::Done; return Poll::Ready(Err(Error::Old)); } @@ -2439,10 +2485,12 @@ impl GroupServe { let stream = match ready!(self.ctx.session.poll_open_uni(&mut cx)) { Ok(stream) => stream, Err(err) => { + self.settle_open(false); self.state = GroupState::Done; return Poll::Ready(Err(Error::from_transport(err))); } }; + self.settle_open(true); let mut writer = Writer::new(stream, self.ctx.version); writer.set_priority(self.priority.send_order()); @@ -2772,6 +2820,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + opens: Default::default(), }; let track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); @@ -2812,6 +2861,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + opens: Default::default(), }; let track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); @@ -2856,6 +2906,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + opens: Default::default(), }; let track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); @@ -2918,6 +2969,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + opens: Default::default(), }; let track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); @@ -2987,6 +3039,7 @@ mod serve_group_test { track_priority_seen: 0, version: Version::Lite06, timescale: Some(crate::Timescale::default()), + opens: Default::default(), }; let track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); @@ -3011,6 +3064,108 @@ mod serve_group_test { "rank 0 must reach the transport as send order 255: {priorities:?}", ); } + + /// A lite-07 subscription's run loop, from group 0, and the log of its subscribe stream. + fn lite07_run( + session: SinkSession, + track: track::Subscriber, + ) -> (TrackRun, Stream, Log) { + let log = Log::default(); + let stream = Stream { + writer: Writer::new(SinkSend::new(log.clone()), Version::Lite07), + reader: crate::coding::Reader::new(PendingRecv, Version::Lite07), + }; + let track_priority = kio::Producer::new(0u8); + let ctx = Subscription { + session, + 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: Default::default(), + }; + let bounds = Bounds { + start_group: Some(0), + start_frame: 0, + end_group: None, + end_frame: None, + }; + (TrackRun::new(ctx, track, bounds, track_priority), stream, log) + } + + fn write_group(track: &mut track::Producer, sequence: u64, millis: u64) { + let mut group = track.create_group(group::Info { sequence }).unwrap(); + group + .write_frame(Timestamp::from_millis(millis).unwrap(), b"x".as_slice()) + .unwrap(); + group.finish().unwrap(); + } + + /// SUBSCRIBE_END counts the group streams opened, not the groups below the end: a + /// group the track never produced has no stream and is not counted. + #[tokio::test] + async fn lite07_end_counts_the_streams_opened() { + let mut track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); + let subscriber = track.subscribe(None); + let (mut run, mut stream, log) = lite07_run(SinkSession::new(Log::default()), subscriber); + let mut run = std::pin::pin!(kio::wait(move |waiter| run.poll(&mut stream, waiter))); + + write_group(&mut track, 0, 0); + assert!(futures::poll!(run.as_mut()).is_pending()); + write_group(&mut track, 2, 2); + track.finish().unwrap(); + assert!(matches!(run.await.unwrap(), TrackEnd::Finished)); + + // SUBSCRIBE_START at 0, then SUBSCRIBE_END at 3 with 2 streams. + assert_eq!(*log.writes.lock().unwrap(), [0, 1, 0, 1, 2, 3, 2]); + } + + /// A track that ends without a group still ends the subscription, with no stream owed. + #[tokio::test] + async fn lite07_end_counts_zero_streams() { + let track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); + let subscriber = track.subscribe(None); + track.finish().unwrap(); + + let (mut run, mut stream, log) = lite07_run(SinkSession::new(Log::default()), subscriber); + kio::wait(|waiter| run.poll(&mut stream, waiter)).await.unwrap(); + assert_eq!(*log.writes.lock().unwrap(), [1, 2, 0, 0]); + } + + /// The count is sent once every served group has opened its stream or given up, so a + /// group still waiting for stream credit holds SUBSCRIBE_END back, and one that expires + /// first is never counted. + #[tokio::test] + async fn lite07_end_waits_for_every_stream_to_open() { + tokio::time::pause(); + + let gate = kio::Producer::new(false); + let mut track = track::Producer::new(Arc::new(broadcast::Info::default()), "test", None); + let subscriber = track.subscribe(None); + write_group(&mut track, 0, 0); + + let (mut run, mut stream, log) = lite07_run(SinkSession::gated_open_uni(gate.consume()), subscriber); + let mut run = std::pin::pin!(kio::wait(move |waiter| run.poll(&mut stream, waiter))); + assert!(futures::poll!(run.as_mut()).is_pending()); + + // Group 1 lands a second later, expiring group 0 before it ever opened. + tokio::time::advance(Duration::from_secs(1)).await; + write_group(&mut track, 1, 1000); + track.finish().unwrap(); + assert!(futures::poll!(run.as_mut()).is_pending(), "group 1 is still opening"); + assert_eq!(*log.writes.lock().unwrap(), [0, 1, 0], "only SUBSCRIBE_START so far"); + + let Ok(mut open) = gate.write() else { + panic!("transport gate closed"); + }; + *open = true; + drop(open); + run.await.unwrap(); + assert_eq!(*log.writes.lock().unwrap(), [0, 1, 0, 1, 2, 2, 1]); + } } #[cfg(test)] diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index 5de12e097f..4656e94521 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -322,6 +322,9 @@ impl Message for SubscribeStart { #[derive(Clone, Debug)] pub struct SubscribeEnd { pub group: u64, + /// The number of group streams the publisher opened for this subscription. + /// Lite07+ only; not on the wire before, where it decodes as 0. + pub streams: u64, } impl Message for SubscribeEnd { @@ -329,16 +332,23 @@ impl Message for SubscribeEnd { if !version.has_track_stream() { return Err(DecodeError::Version); } - Ok(Self { - group: u64::decode(r, version)?, - }) + let group = u64::decode(r, version)?; + let streams = match version.has_stream_count() { + true => u64::decode(r, version)?, + false => 0, + }; + Ok(Self { group, streams }) } fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { if !version.has_track_stream() { return Err(EncodeError::Version); } - self.group.encode(w, version) + self.group.encode(w, version)?; + if version.has_stream_count() { + self.streams.encode(w, version)?; + } + Ok(()) } } @@ -429,7 +439,7 @@ impl Message for SubscribeUpdate { /// The range `[start, end]` is inclusive on both ends. For example, /// `start = 5, end = 7` means groups 5, 6, and 7 were dropped. /// -/// Lite03+ only. +/// Lite03 to Lite06 only: Lite07 counts group streams in [`SubscribeEnd`] instead. #[derive(Clone, Debug)] pub struct SubscribeDrop { /// The first absolute group sequence in the dropped range. @@ -449,6 +459,7 @@ impl Message for SubscribeDrop { Version::Lite01 | Version::Lite02 => { return Err(DecodeError::Version); } + _ if version.has_stream_count() => return Err(DecodeError::Version), _ => {} } @@ -464,6 +475,7 @@ impl Message for SubscribeDrop { Version::Lite01 | Version::Lite02 => { return Err(EncodeError::Version); } + _ if version.has_stream_count() => return Err(EncodeError::Version), _ => {} } @@ -480,8 +492,9 @@ impl Message for SubscribeDrop { /// /// The discriminator is version-dependent: /// - Lite03/04: `0x0` SUBSCRIBE_OK, `0x1` SUBSCRIBE_DROP. -/// - Lite05+: `0x0` SUBSCRIBE_START, `0x1` SUBSCRIBE_END, `0x2` SUBSCRIBE_DROP +/// - Lite05/06: `0x0` SUBSCRIBE_START, `0x1` SUBSCRIBE_END, `0x2` SUBSCRIBE_DROP /// (SUBSCRIBE_OK was removed; acceptance is implicit). +/// - Lite07+: `0x0` SUBSCRIBE_START, `0x1` SUBSCRIBE_END (SUBSCRIBE_DROP was removed). #[derive(Clone, Debug)] pub enum SubscribeResponse { Ok(SubscribeOk), @@ -525,8 +538,8 @@ impl Encode for SubscribeResponse { _ => match self { Self::Start(start) => encode_typed(w, 0, start, version)?, Self::End(end) => encode_typed(w, 1, end, version)?, - Self::Drop(drop) => encode_typed(w, 2, drop, version)?, - Self::Ok(_) => return Err(EncodeError::Version), + Self::Drop(drop) if !version.has_stream_count() => encode_typed(w, 2, drop, version)?, + Self::Drop(_) | Self::Ok(_) => return Err(EncodeError::Version), }, } @@ -551,7 +564,7 @@ impl Decode for SubscribeResponse { match typ { 0 => Ok(Self::Start(SubscribeStart::decode(buf, version)?)), 1 => Ok(Self::End(SubscribeEnd::decode(buf, version)?)), - 2 => Ok(Self::Drop(SubscribeDrop::decode(buf, version)?)), + 2 if !version.has_stream_count() => Ok(Self::Drop(SubscribeDrop::decode(buf, version)?)), _ => Err(DecodeError::InvalidMessage(typ)), } } @@ -577,16 +590,53 @@ mod test { #[test] fn subscribe_end_roundtrips_on_lite05() { - let resp = SubscribeResponse::End(SubscribeEnd { group: 7 }); + let resp = SubscribeResponse::End(SubscribeEnd { group: 7, streams: 3 }); let mut buf = Vec::new(); resp.encode(&mut buf, Version::Lite05).unwrap(); + // Type, length, group: no stream count before lite-07. + assert_eq!(buf, [1, 1, 7]); let mut slice = buf.as_slice(); match SubscribeResponse::decode(&mut slice, Version::Lite05).unwrap() { - SubscribeResponse::End(end) => assert_eq!(end.group, 7), + SubscribeResponse::End(end) => assert_eq!((end.group, end.streams), (7, 0)), + other => panic!("expected End, got {other:?}"), + } + } + + #[test] + fn subscribe_end_carries_the_stream_count_on_lite07() { + let resp = SubscribeResponse::End(SubscribeEnd { group: 7, streams: 3 }); + let mut buf = Vec::new(); + resp.encode(&mut buf, Version::Lite07).unwrap(); + assert_eq!(buf, [1, 2, 7, 3]); + let mut slice = buf.as_slice(); + match SubscribeResponse::decode(&mut slice, Version::Lite07).unwrap() { + SubscribeResponse::End(end) => assert_eq!((end.group, end.streams), (7, 3)), other => panic!("expected End, got {other:?}"), } } + #[test] + fn subscribe_drop_is_gone_on_lite07() { + let resp = SubscribeResponse::Drop(SubscribeDrop { + start: 1, + end: 3, + error: 0, + }); + let mut buf = Vec::new(); + assert!(matches!( + resp.encode(&mut buf, Version::Lite07), + Err(EncodeError::Version) + )); + + // A lite-06 DROP is an unknown response type on lite-07. + let mut buf = Vec::new(); + resp.encode(&mut buf, Version::Lite06).unwrap(); + assert!(matches!( + SubscribeResponse::decode(&mut buf.as_slice(), Version::Lite07), + Err(DecodeError::InvalidMessage(2)) + )); + } + #[test] fn subscribe_drop_is_type_2_on_lite05() { let resp = SubscribeResponse::Drop(SubscribeDrop { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 1b05e64318..45b4d91718 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -15,6 +15,7 @@ use crate::{ }; use super::Version; +use crate::tail::{self, Settle, Tail}; use kio::Lock; @@ -87,6 +88,8 @@ struct TrackEntry { /// Timestamp scale from this track's TRACK_INFO, known before the SUBSCRIBE is /// even opened, so group streams decode frames without blocking. timescale: Option, + /// The groups received so far, so the subscription's end can wait for the ones owed. + tail: kio::Producer, } impl Subscriber { @@ -438,6 +441,10 @@ impl Subscriber { let timestamp = Timestamp::new(dg.timestamp, scale).map_err(|_| Error::BoundsExceeded(crate::coding::BoundsExceeded))?; + // A datagram is never owed a stream, so it never holds the subscription's end open. + if let Ok(mut tail) = entry.tail.write() { + tail.account(dg.sequence..dg.sequence.saturating_add(1)); + } entry.producer.insert_datagram(dg.sequence, timestamp, dg.payload)?; Ok(()) } @@ -721,6 +728,9 @@ impl GroupRecv { let (group, track, timescale) = { let mut subs = self.subscriber.subscribes.lock(); let entry = subs.get_mut(&hdr.subscribe).ok_or(Error::Cancel)?; + if let Ok(mut tail) = entry.tail.write() { + tail.open(hdr.sequence); + } let group_info = group::Info { sequence: hdr.sequence }; // Stats (groups/frames/bytes) are counted in the model as the group @@ -1369,6 +1379,7 @@ mod tests { TrackEntry { producer, timescale: Some(Timescale::default()), + tail: Default::default(), }, ); @@ -2489,6 +2500,24 @@ struct SubStream { /// start sits elsewhere the request-tracked floor stands instead, and demand /// returning here makes the declaration valid again. requested: Option, + /// The groups received for this subscription, shared with its [`TrackEntry`]. + 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, +} + +impl SubStream { + /// The groups the publisher still owes once it has ended the subscription. + /// + /// `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 = requested_end.map_or(end, |requested| requested.min(end)); + Some(self.served.unwrap_or(end)..end) + } } enum Sub { @@ -2781,16 +2810,23 @@ impl TrackServe { // The floor tracks the requested start until this subscription's own // SUBSCRIBE_START refines it: the peer never serves below the request, a // previous subscription's declaration must not outlive its demand, and - // live-edge demand (None) starts with no floor at all. - let _ = producer.start_at(subscription.start.map(|start| start.group)); + // live-edge demand (None) starts with no floor at all. Lite-06+ only resolves + // the start with its SUBSCRIBE_START, so until then the floor is just a request. + let floor = subscription.start.map(|start| start.group); + let _ = match self.subscriber.version.resolves_start() { + true => producer.request_start(floor), + false => producer.start_at(floor), + }; tracing::info!(id, broadcast = %self.subscriber.log_path(&self.path), track = %self.name, "subscribe started"); + let tail = kio::Producer::new(Tail::default()); self.subscriber.subscribes.lock().insert( id, TrackEntry { producer: producer.clone(), timescale, + tail: tail.clone(), }, ); @@ -2801,6 +2837,7 @@ impl TrackServe { session, id, subscription, + tail, state: EstablishState::Open, } } @@ -2900,6 +2937,7 @@ struct Establish { closed: S, id: u64, subscription: Subscription, + tail: kio::Producer, state: EstablishState, } @@ -2984,6 +3022,9 @@ impl Establish { start: self.subscription.start, priority: self.subscription.priority, requested: self.subscription.start, + tail: self.tail.clone(), + served: None, + end: None, } } } @@ -3198,6 +3239,13 @@ enum ServeMode { /// Driving an upstream SUBSCRIBE open. The demand arms wait meanwhile, /// 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. + Tail { + settle: Settle, + owed: Option>, + }, } impl ServeLoop { @@ -3206,6 +3254,12 @@ impl ServeLoop { // request's own fetch gate, and a cache-miss fetch queued while TRACK_INFO // was in flight would be drained as NotFound in the gap. let dynamic = request.dynamic(); + // Lite-06+ resolves each subscription's start from its budget, so the floor + // the SUBSCRIBE asks for is not where delivery begins until SUBSCRIBE_START says. + let request = match serve.subscriber.version.resolves_start() { + true => request.resolving_start(), + false => request, + }; let serving = request.accept(info); Self { serving, @@ -3237,6 +3291,23 @@ impl ServeLoop { } } } + ServeMode::Tail { settle, owed } => { + let _ = self.fetches.poll(waiter); + if settle + .poll(waiter, |tail| owed.clone().is_some_and(|owed| tail.covers(owed))) + .is_ready() + { + return Poll::Ready(ServeEnd::Finished); + } + if self.fetches.is_empty() && self.serving.poll_unused(waiter).is_ready() { + return Poll::Ready(ServeEnd::Idle); + } + let mut cx = std::task::Context::from_waker(waiter.waker()); + if self.closed.poll_closed(&mut cx).is_ready() { + return Poll::Ready(ServeEnd::GiveBack(Error::Dropped)); + } + return Poll::Pending; + } ServeMode::Select => { let mut cx = waiter.context(); @@ -3336,6 +3407,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); } // SUBSCRIBE_START names the first group this feed serves: // the publisher skipped everything below it (e.g. it could @@ -3352,21 +3424,50 @@ impl ServeLoop { if active.start == active.requested { let _ = self.serving.start_at(start.group); } + active.served = Some(start.group); + } + // The publisher will never send these groups, so they + // are accounted for without a stream. + lite::SubscribeResponse::Drop(dropped) => { + if let Ok(mut tail) = active.tail.write() { + tail.account(dropped.start..dropped.end.saturating_add(1)); + } + } + // OK just resolves the range (the producer already orders + // groups). + lite::SubscribeResponse::Ok(_) => { + tracing::debug!(track = %serve.name, ?msg, "subscribe response") } - // OK/DROP just resolve the range (the producer already - // orders groups). - _ => tracing::debug!(track = %serve.name, ?msg, "subscribe response"), } continue; } Ok(None) => { tracing::info!(broadcast = %serve.subscriber.log_path(&serve.path), track = %serve.name, "subscribe complete"); // Upstream FIN'd the subscription: the publisher only FINs - // once the track's final sequence is known and delivered, so - // the logical track is over for good (bounded downstream - // demand alone never FINs; the publisher parks, since a cap - // can be raised). - return Poll::Ready(ServeEnd::Finished); + // once the track's final sequence is known and every group + // stream finished, so the logical track is over for good + // (bounded downstream demand alone never FINs; the publisher + // parks, since a cap can be raised). Those streams can still + // be in flight, so wait for the tail before finishing. + let subscription = self.serving.subscription(); + let requested_end = subscription.as_ref().and_then(|sub| sub.end).map(|end| match end + .frame + { + 0 => end.group, + _ => end.group.saturating_add(1), + }); + // The effective max age is the stopgap grace: the wrong clock + // (it bounds presentation-time drift), but it is how long the + // subscriber was willing to wait for a late group anyway. + let grace = subscription + .map(|sub| sub.max_age) + .filter(|max_age| !max_age.is_zero()) + .unwrap_or(tail::GRACE); + self.mode = ServeMode::Tail { + settle: Settle::new(&serve.subscriber.runtime, active.tail.consume(), grace), + owed: active.owed(requested_end), + }; + continue; } Err(err) => { tracing::warn!(broadcast = %serve.subscriber.log_path(&serve.path), track = %serve.name, %err, "subscribe error"); diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index ad57336503..72e4ffa30b 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -15,15 +15,29 @@ pub enum Version { /// implicitly assigns the next ordinal, and `ended`/`restart` reference that id /// instead of repeating the path. Also adds the route cost carried alongside the /// hop chain, ranking above hop count in route selection. Advertised over ALPN - /// as `moq-lite-06`. + /// as `moq-lite-06` and preferred by the default version sets. Lite06, /// Lite-07. Adds the hidden opt-in to ANNOUNCE_REQUEST: without it, a route with - /// a `.`-prefixed segment below the requested prefix is left out. Advertised over - /// ALPN as `moq-lite-07` and preferred by the default version sets. + /// a `.`-prefixed segment below the requested prefix is left out. SUBSCRIBE_END + /// carries the number of group streams opened, replacing SUBSCRIBE_DROP. + /// The wire format is still work-in-progress, so it is advertised over ALPN as + /// `moq-lite-07-wip` and only when explicitly requested; the default version sets + /// leave it out. Lite07, } impl Version { + /// Whether SUBSCRIBE_END carries the subscription's group stream count, sent once + /// every counted stream is open, in place of SUBSCRIBE_DROP. Added in lite-07. + #[allow(clippy::match_like_matches_macro)] + pub fn has_stream_count(self) -> bool { + // Match form so future versions default forward (CLAUDE.md convention). + match self { + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, + _ => true, + } + } + /// Whether the version has lite-05's dedicated TRACK stream and related stream /// layout changes. /// @@ -203,7 +217,7 @@ impl fmt::Display for Version { Self::Lite04 => write!(f, "moq-lite-04"), Self::Lite05 => write!(f, "moq-lite-05"), Self::Lite06 => write!(f, "moq-lite-06"), - Self::Lite07 => write!(f, "moq-lite-07"), + Self::Lite07 => write!(f, "moq-lite-07-wip"), } } } diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index c651082fb7..df6a582a65 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -105,7 +105,8 @@ 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 delivered groups of `track` too: the linger expired unread. + /// 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 }, /// The logical track completed. Finish { track: Arc }, @@ -587,6 +588,13 @@ 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. + TrackState::Spliced { .. } if self.identity == Identity::Local => { + track.state = TrackState::Idle; + actions.push(Action::Release { track: name }); + } // Drop the copy so the source goes idle at once; the delivered // groups stay spliced for the linger. TrackState::Spliced { .. } => { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 46ade80ef7..79fc45d341 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1,4 +1,4 @@ -use crate::{broadcast, cache, stats, track}; +use crate::{broadcast, cache, group, stats, track}; use kio::Task; use std::{ cmp::Reverse, @@ -1815,6 +1815,9 @@ const TRACK_IDLE_LINGER: Duration = Duration::from_secs(30); struct WarmCopy { track: track::Producer, _dynamic: track::Dynamic, + /// The newest group, where the copy spliced after this one picks up (see + /// [`TrackIo::head`]). + edge: Option, } impl Drop for WarmCopy { @@ -1823,25 +1826,142 @@ impl Drop for WarmCopy { } } -/// Cache `source`'s finished groups on a new local track the origin owns. -fn warm_copy(source: &track::Consumer) -> Option { +/// A warm copy's newest group, which the copy spliced after it continues. +/// +/// Kept past that splice: an open one must stay open for the continuation (dropping +/// an unfinished producer clears its frames), and either kind supplies the head the +/// continuation lacks when the next park rebuilds the group. Nothing will ever finish +/// an open one, so it aborts on drop. +struct WarmGroup(group::Producer); + +impl Drop for WarmGroup { + fn drop(&mut self) { + if !self.0.is_finished() { + let _ = self.0.clone().abort(Error::Cancel); + } + } +} + +/// Cache what `source` delivered on a new local track the origin owns: its complete +/// groups, and its open live edge rebuilt from the frames already delivered. +/// +/// `head` is the previous park's edge. A copy spliced after a warm cache continues its +/// edge group from the next frame, so its copy of that group lacks the head, which +/// `head` supplies. +fn warm_copy(source: &track::Consumer, head: Option<&WarmGroup>) -> Option { let info = source.cached_info()?; let mut track = track::Producer::new(Arc::new(source.broadcast().clone()), source.name(), info); - for (group, visible) in source.cached_groups() { - // An open group is left for the re-splice to deliver whole. Dropping the source - // copy resets it mid-transfer, and its dead head would anchor the next takeover - // mid-group, asking upstream for a tail no returning reader can use. - if group.is_finished() { - let _ = track.adopt_group(group, visible); + let head = head.map(|head| &head.0); + let groups = source.cached_groups(); + // Not `source.latest()`: datagrams share the sequence counter and can run past it. + let latest = groups + .iter() + .filter(|(_, visible)| *visible) + .map(|(group, _)| group.sequence) + .max(); + let mut edge = None; + + // A spliced copy hides the group it continued (its halves sit in two segments), so + // carry the previous edge over when the copy has no version of it at all. First, + // since it arrived before anything the copy holds. + if let Some(head) = head + && !groups.iter().any(|(group, _)| group.sequence == head.sequence) + { + let is_latest = latest.is_none_or(|latest| head.sequence >= latest); + if head.is_finished() { + let _ = track.adopt_group(head.clone(), true); + if is_latest { + edge = Some(WarmGroup(head.clone())); + } + } else if is_latest { + edge = warm_rebuild(&track, head, None); + } + } + + for (group, visible) in groups { + let finished = group.is_finished(); + let whole = group.live_first_frame() == Some(0); + let is_latest = visible && Some(group.sequence) == latest; + let warm = if finished && whole { + let _ = track.adopt_group(group.clone(), visible); + Some(WarmGroup(group)) + } else if (finished || is_latest) && (whole || head.is_some_and(|head| head.sequence == group.sequence)) { + // Dropping the source copy resets an open live edge mid-transfer, so rebuild + // it from the frames already delivered: the re-splice asks for the next + // frame, and a group that stays open for good (a JSON log in group 0) + // continues instead of being re-sent whole on every resume. A continuation + // is rebuilt whole from the previous edge's head the same way. + warm_rebuild(&track, &group, head) + } else { + // Mid-transfer backlog, or a continuation with no head to complete it. + None + }; + if is_latest { + edge = warm; } } let dynamic = track.dynamic(); Some(WarmCopy { track, _dynamic: dynamic, + edge, }) } +/// Rebuild `live` on `track` from its delivered frames, prefixed by `head`'s when `live` +/// only holds a continuation of it. Finished like `live`, or left open. +fn warm_rebuild(track: &track::Producer, live: &group::Producer, head: Option<&group::Producer>) -> Option { + // A continuation holds nothing below its offset. + let mut start = live.live_first_frame()? as u64; + let mut tail = live.consume(); + tail.start_at(start); + let mut frames = Vec::new(); + if start > 0 + && let Some(head) = head.filter(|head| head.sequence == live.sequence) + && let Some(head_start) = head.live_first_frame() + { + let mut head = head.consume(); + head.start_at(head_start as u64); + while head.index() < start { + match head.poll_read_frame(&kio::Waiter::noop()) { + Poll::Ready(Ok(Some(frame))) => frames.push(frame), + _ => break, + } + } + match head.index() == start { + true => start = head_start as u64, + // The head doesn't reach the continuation: keep only the continuation. + false => frames.clear(), + } + } + while let Poll::Ready(Ok(Some(frame))) = tail.poll_read_frame(&kio::Waiter::noop()) { + frames.push(frame); + } + if frames.is_empty() { + return None; + } + + // Wrapped first, so a failed write aborts it rather than dropping it unfinished. + let rebuilt = WarmGroup( + track + .create_group(group::Info { + sequence: live.sequence, + }) + .ok()?, + ); + let mut writer = rebuilt.0.clone(); + if start > 0 { + writer.start_at(start).ok()?; + } + for frame in frames { + writer.write_frame(frame.timestamp, frame.payload).ok()?; + } + if live.is_finished() { + writer.finish().ok()?; + } + Some(rebuilt) +} + /// Everything [`run_front`] owns, queued by [`Consumer::request_broadcast`]. struct FrontTask { /// The route table the front selects from. @@ -1879,6 +1999,9 @@ struct TrackIo { /// Delivered groups kept after the copy was dropped, so resume stays spliced /// through the linger without pinning the source as a reader. warm: Option, + /// The last warm copy's newest group, outliving it so the copy spliced after it + /// can continue the group (see [`WarmGroup`]). Released at the next park. + head: Option, /// Whether the track had a reader as of the last demand edge. used: bool, } @@ -2106,7 +2229,7 @@ async fn run_front(task: FrontTask) { tracks.remove(&name); continue; } - io.warm = None; + io.head = io.warm.take().and_then(|mut warm| warm.edge.take()); // The new segment has produced nothing yet: this is the // edge the copy is asked to advance. io.edge = io.resume.resume_position(); @@ -2118,24 +2241,25 @@ async fn run_front(task: FrontTask) { // Drop the source copy so its producer goes idle at once; keep // the groups it delivered on a local track so resume stays // spliced until the linger expires. - let warm = warm_copy(©); + let warm = warm_copy(©, io.head.as_ref()); drop(copy); - if io.resume.release().is_err() { + io.head = None; + let parked = match &warm { + Some(warm) => io.resume.park(&warm.track), + None => io.resume.release(), + }; + if parked.is_err() { tracks.remove(&name); continue; } - if let Some(warm) = warm { - if let Err(err) = io.resume.takeover(&warm.track) { - let _ = io.resume.abort(err); - tracks.remove(&name); - continue; - } - io.warm = Some(warm); - } + 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); } @@ -2252,6 +2376,7 @@ async fn run_front(task: FrontTask) { copy: None, edge: None, warm: None, + head: None, used: false, }, ); @@ -4615,6 +4740,211 @@ mod tests { assert!(matches!(subscription.recv_group().await, Ok(None)), "ends cleanly"); } + /// A front resolved through a served route, as a relay's upstream session serves + /// one: the upstream broadcast's track requests arrive on the returned handle. + async fn served_front() -> (Dynamic, broadcast::Producer, broadcast::Dynamic, broadcast::Consumer) { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let server = producer + .dynamic("room/alice", Route::default().with_hops(hops(&[10]))) + .unwrap(); + let pending = consumer.request_broadcast("room/alice"); + let upstream = broadcast::Info::new().produce(); + let dynamic = upstream.dynamic(); + queued(&server).await.accept(&upstream); + let resolved = pending.await.expect("resolves"); + (server, upstream, dynamic, resolved) + } + + /// A reader returning to a parked track waits for the fresh copy to resolve its + /// start, and skips the warm cache when the copy resolves past it: the source + /// judged the groups in between stale, so the older cache is stale too. Without the + /// hold the reader was handed the whole warm cache first, seconds behind live. + #[tokio::test] + async fn returning_reader_skips_a_warm_cache_the_copy_resolved_past() { + let ms = |v: u64| crate::Timestamp::from_millis(v).unwrap(); + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + let budget = track::Subscription::default().with_max_age(Duration::from_millis(100)); + + let track = resolved.track("audio").unwrap(); + let b = budget.clone(); + let subscribing = tokio::spawn(async move { track.subscribe(b).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); + for seq in 0..4u64 { + let mut group = source.create_group(seq.into()).unwrap(); + group.write_frame(ms(seq * 20), b"old".as_ref()).unwrap(); + group.finish().unwrap(); + } + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + subscription.recv_group().await.unwrap().expect("the live group"); + drop(subscription); + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(source); + + let track = resolved.track("audio").unwrap(); + let subscribing = tokio::spawn(async move { track.subscribe(budget).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source again") + .expect("request"); + let mut source = request.resolving_start().accept(None); + let mut subscription = subscribing.await.unwrap().expect("resubscribe"); + + // The copy has not resolved its start: nothing is handed out yet. + assert!( + tokio::time::timeout(Duration::from_millis(50), subscription.recv_group()) + .await + .is_err(), + "the warm cache was served before the copy resolved its start" + ); + + // The source resolves past the floor (lite-06 skipped 4..20 as stale). + source.start_at(20).unwrap(); + let mut group = source.create_group(20u64.into()).unwrap(); + group.write_frame(ms(2000), b"new".as_ref()).unwrap(); + group.finish().unwrap(); + let group = subscription.recv_group().await.unwrap().expect("the live group"); + assert_eq!(group.sequence, 20, "a stale warm group was served"); + } + + /// A warm cache whose newest group finished still resumes when the source has + /// nothing newer: the re-splice asks for that group's tail, which a source that + /// resolves starts lazily (with its first served group) can answer at once. Asking + /// past it left a returning catalog reader waiting for the next catalog change. + #[tokio::test] + async fn returning_reader_replays_a_current_warm_cache() { + 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(); + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + subscription.recv_group().await.unwrap().expect("the catalog"); + drop(subscription); + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(source); + + 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 again") + .expect("request"); + let mut source = request.resolving_start().accept(None); + let mut subscription = subscribing.await.unwrap().expect("resubscribe"); + + // The source still has group 0 as its newest: it serves the empty tail, and + // that is when its start resolves. + let reading = tokio::spawn(async move { + let mut group = subscription.recv_group().await.unwrap().expect("the catalog"); + assert_eq!(group.sequence, 0); + group.read_frame().await.unwrap().expect("the snapshot").payload + }); + tokio::task::yield_now().await; + assert_eq!( + source.subscription().and_then(|sub| sub.start), + Some(track::Position { group: 0, frame: 1 }), + "the re-splice asked past the cached catalog" + ); + source.start_at(0).unwrap(); + let mut tail = source.create_group(0u64.into()).unwrap(); + tail.start_at(1).unwrap(); + tail.finish().unwrap(); + let payload = tokio::time::timeout(Duration::from_secs(1), reading) + .await + .expect("the returning reader never got the catalog") + .unwrap(); + assert_eq!(&payload[..], b"snapshot"); + } + + /// 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. + /// A datagram sequenced past the group does not hide it as the live edge. + #[tokio::test] + async fn returning_reader_continues_an_open_warm_group() { + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + + async fn read(group: &mut group::Consumer) -> Vec { + let frame = tokio::time::timeout(Duration::from_secs(1), group.read_frame()) + .await + .expect("frame") + .unwrap() + .expect("group ended"); + frame.payload.to_vec() + } + + let mut expect: Vec<&[u8]> = Vec::new(); + let mut floor: Option = None; + for (round, payload) in [b"a".as_ref(), b"b", b"c"].into_iter().enumerate() { + let track = resolved.track("log").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 mut source = request.resolving_start().accept(None); + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + + // The source resolves at the floor's group, continuing group 0. + source.start_at(0).unwrap(); + let mut group = source.create_group(0u64.into()).unwrap(); + if let Some(floor) = floor { + group.start_at(floor.frame).unwrap(); + } + group.write_frame(crate::Timestamp::ZERO, payload).unwrap(); + expect.push(payload); + source + .insert_datagram(10, crate::Timestamp::ZERO, b"datagram".as_ref()) + .unwrap(); + + let mut reading = tokio::time::timeout(Duration::from_secs(1), subscription.recv_group()) + .await + .expect("group 0") + .unwrap() + .expect("track ended"); + assert_eq!(reading.sequence, 0); + for frame in &expect { + assert_eq!(read(&mut reading).await, *frame, "round {round}"); + } + assert_eq!( + source.subscription().and_then(|sub| sub.start), + floor, + "round {round} asked for the wrong continuation" + ); + + drop(reading); + drop(subscription); + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(group); + drop(source); + floor = Some(track::Position { + group: 0, + frame: expect.len() as u64, + }); + } + } + /// The same holds for a reader returning to a parked track: its warm cache /// does not stand in for the copy it is waiting on. #[tokio::test] @@ -5961,6 +6291,77 @@ mod tests { assert_eq!(&group.read_frame().await.unwrap().unwrap().payload[..], b"live"); } + /// A returning reader judges the warm cache against the logical track's live + /// edge, not the parked segment's own frozen one: groups the fresh source + /// has left behind by more than the budget are skipped, exactly as they would + /// be on one unspliced track. + /// + /// A local source is released rather than parked (it keeps its own cache), so + /// this goes through a served front, which is what actually holds the warm copy. + /// The copy resolves at the cached edge, not past it: resolving past it drops + /// the cache outright, which is a different case. + #[tokio::test] + async fn resumed_reader_skips_warm_groups_behind_the_new_edge() { + let ms = |v: u64| crate::Timestamp::from_millis(v).unwrap(); + let (_server, _upstream, mut dynamic, resolved) = served_front().await; + let budget = track::Subscription::default().with_max_age(Duration::from_millis(100)); + let write = |source: &track::Producer, sequence: u64, millis: u64| { + let mut group = source.create_group(sequence.into()).unwrap(); + group.write_frame(ms(millis), b"x".as_ref()).unwrap(); + group.finish().unwrap(); + }; + let drain = |subscription: &mut track::Subscriber| { + let mut sequences = Vec::new(); + while let Poll::Ready(group) = subscription.poll_recv_group(&kio::Waiter::noop()) { + sequences.push(group.unwrap().expect("track ended").sequence); + } + sequences + }; + + let track = resolved.track("video").unwrap(); + let first = budget.clone(); + let subscribing = tokio::spawn(async move { track.subscribe(first).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); + for (sequence, millis) in [(0, 0), (1, 20), (2, 40), (3, 60)] { + write(&source, sequence, millis); + } + let mut subscription = subscribing.await.unwrap().expect("subscribe"); + next_group(&mut subscription).await.unwrap().expect("a cached group"); + drain(&mut subscription); + drop(subscription); + + tokio::time::timeout(Duration::from_secs(1), source.unused()) + .await + .expect("parked") + .expect("source open"); + drop(source); + + let track = resolved.track("video").unwrap(); + let second = budget.clone(); + let subscribing = tokio::spawn(async move { track.subscribe(second).await }); + let request = tokio::time::timeout(Duration::from_secs(1), dynamic.requested_track()) + .await + .expect("the front asked the source again") + .expect("request"); + let mut source = request.resolving_start().accept(None); + for (sequence, millis) in [(20, 400), (21, 420), (22, 440)] { + write(&source, sequence, millis); + } + // At the cached edge, not past it, so the warm copy stays and the budget + // decides. Past it, the copy has already judged the cache stale. + source.start_at(3).unwrap(); + let mut subscription = subscribing.await.unwrap().expect("resubscribe"); + settle(|| subscription.latest() == Some(22)).await; + + // Groups 0..=2 reach at most 60ms against an edge at 440ms. Group 3 reaches + // where group 20 starts, 40ms behind that edge, inside the 100ms budget. + assert_eq!(drain(&mut subscription), [3, 20, 21, 22]); + } + /// A front serving from another front's spliced copy has no snapshot to keep: /// it still drops upstream on the unused edge, so the publisher's `unused()` /// resolves far below `TRACK_IDLE_LINGER` through the whole chain. The next @@ -6015,10 +6416,11 @@ mod tests { "every front keeps the delivered groups after releasing its source" ); + // A budget spanning the cache, so the returning reader replays it. let mut subscription = edge_resolved .track("video") .unwrap() - .subscribe(None) + .subscribe(track::Subscription::default().with_max_age(Duration::from_secs(3600))) .await .expect("resubscribe"); tokio::time::timeout(Duration::from_secs(5), track.used()) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 68e7005069..a0dd203287 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -26,7 +26,10 @@ use std::collections::{BTreeMap, HashSet}; use std::ops::Bound; use std::task::{Poll, ready}; +#[cfg(test)] +use crate::Timestamp; use crate::{Datagram, Error, Result, frame, group, track}; +use track::{Anchor, LiveEdge, Successor}; use super::subscription::{Cap, Position, Subscription, max_some, min_some}; @@ -43,6 +46,12 @@ struct Segment { end: Option, /// The underlying per-session track. track: track::Consumer, + /// Where the source is asked to start: `start`, except after a warm cache (see + /// [`Segment::warm_edge`]). + ask: Option, + /// A parked track's warm cache (see [`Producer::park`]), which live readers hold + /// until the next segment's copy resolves its start. + warm: bool, } impl Segment { @@ -72,6 +81,23 @@ impl Segment { }) } + /// Where a copy spliced after this warm cache is asked to start: the end of the + /// cache's newest group, even a finished one, rather than the head of the next. + /// + /// A source only resolves a start once it has a group to serve, so asking past its + /// newest group would leave a returning reader waiting on the next one. Asking for the + /// newest group's tail lets a source that is still there answer at once, and one that + /// moved on answer past it. Only the ask moves: the boundary stays where the cache + /// stops, so whatever the source sends for a finished group's empty tail (a FIN, or a + /// reset from a publisher that refuses empty ranges) sits outside the new segment. + fn warm_edge(&self) -> Option { + let group = self.track.peek_latest()?; + Some(Position { + group: group.sequence, + frame: group.frame_count() as u64, + }) + } + /// The newest cached group this segment serves below `before` (exclusive), or its /// newest cached group at all when `before` is `None`. /// @@ -118,6 +144,18 @@ fn slice(prefs: &Subscription, start: Option, end: Option) - } } +/// The first servable group in `from..cap` across `segments`, with the slot identity a +/// later judgment needs. An unstamped group stops the search: skipping it for a later +/// start would shrink a reach that is not yet proven. +fn served_start(segments: &[Segment], from: u64, cap: Option) -> Option { + segments.iter().find_map(|segment| { + let start = segment.start.map_or(0, |start| start.group).max(from); + segment + .track + .served_start(start, min_some(cap, last_group(segment.end))) + }) +} + /// How many segments a logical track keeps before pruning terminal ones from the /// front: the live segment plus a couple of predecessors still draining to slow /// readers. Without a bound, every failover leaves one dead segment (pinning a @@ -196,6 +234,28 @@ impl ResumeState { } } + /// The newest live edge across the segments below the exclusive `cap`, each clamped + /// to its own range: the edge the logical track measures drift against. + fn live_edge(&self, cap: Option) -> Option { + self.segments + .iter() + .filter_map(|segment| { + let edge = segment.track.live_edge(min_some(cap, last_group(segment.end)))?; + // Out-of-range content below the segment is not its to serve. + let start = segment.start.map_or(0, |start| start.group); + (edge.sequence >= start).then_some(edge) + }) + .max_by_key(|edge| edge.sequence) + } + + /// Where the logical track continues past the exclusive group `boundary` of segment + /// `id`, below the reader's `cap`: the start of the first group the later segments + /// serve there. `None` while none is cached, or it has no frame yet. + fn successor(&self, id: u64, boundary: u64, cap: Option) -> Option { + let index = self.segments.iter().position(|segment| segment.id == id)?; + served_start(&self.segments[index + 1..], boundary, cap) + } + /// Append a segment serving the track from `start` onward, capping (or replacing) /// the previous segments so the ranges stay disjoint and ascending. fn switch(&mut self, track: track::Consumer, start: Option) -> Result<()> { @@ -234,6 +294,8 @@ impl ResumeState { start, end: None, track, + ask: start, + warm: false, }); self.epoch += 1; self.prune(); @@ -339,11 +401,23 @@ impl Producer { // segments at all) there is nothing to splice around, so the replacement // replaces them outright and starts unbounded, exactly like a first splice. // `switch` rejects a `None` start once a segment exists, hence the clear. + let ask = state + .segments + .last() + .filter(|last| last.warm) + .and_then(Segment::warm_edge); let start = state.resume_position(); if start.is_none() { state.segments.clear(); } - state.switch(track, start) + state.switch(track, start)?; + if let Some(ask) = ask + && start.is_some() + && let Some(last) = state.segments.last_mut() + { + last.ask = Some(ask); + } + Ok(()) } /// Drop every segment, releasing the underlying tracks while keeping the @@ -370,6 +444,30 @@ impl Producer { Ok(()) } + /// Replace every segment with `warm`, a cache of what they delivered, for a track + /// nobody reads anymore: [`Self::release`] followed by an unbounded first splice. + /// + /// Live readers hold the cache until the next [`Self::takeover`]'s copy resolves + /// where its feed starts. A copy that picks up at the cache's edge proves the cache + /// still leads into the live feed, so it is read as usual (and a group the cache + /// holds open continues from its next frame). A copy that starts past the edge + /// skipped groups its source already judged stale, so the older cache is stale too + /// and live readers skip it. + pub(crate) fn park(&mut self, warm: impl super::origin_impl::Consume) -> Result<()> { + let track = warm.consume(); + let mut state = self.state.write().map_err(|_| Error::Dropped)?; + if state.finished || state.abort.is_some() { + return Err(Error::Closed); + } + state.segments.clear(); + state.pruned = None; + state.switch(track, None)?; + if let Some(segment) = state.segments.last_mut() { + segment.warm = true; + } + Ok(()) + } + /// Whether any segment is spliced in, and so whether there is anything for /// [`Self::release`] to drop. /// @@ -530,8 +628,8 @@ impl Consumer { next_sequence: 0, min_sequence: 0, end_sequence: None, - stale_cap: None, - drift_cap: kio::Producer::new(None), + outer: Anchor::default(), + drift_anchor: kio::Producer::new(Anchor::default()), } } @@ -598,6 +696,17 @@ impl Consumer { self.state.read().resume_position() } + /// The newest live edge across the segments; see [`track::Consumer::live_edge`]. + pub(crate) fn live_edge(&self, cap: Option) -> Option { + self.state.read().live_edge(cap) + } + + /// Where the first servable group in `from..cap` starts, with the identity to + /// revalidate it; see [`track::Consumer::served_start`]. + pub(crate) fn served_start(&self, from: u64, cap: Option) -> Option { + served_start(&self.state.read().segments, from, cap) + } + /// The newest cached group across every spliced segment; see /// [`track::Consumer::peek_latest`]. pub(crate) fn peek_latest(&self) -> Option { @@ -804,8 +913,8 @@ pub(crate) struct Group { state: kio::Consumer, /// The logical subscription's live max age budget. subscription: kio::Consumer, - /// The logical reader's group cap, used only to bound each route's drift anchor. - cap: kio::Consumer>, + /// The logical reader's drift anchor, used only to judge each route's copy. + anchor: kio::Consumer, /// The logical group being assembled. sequence: u64, @@ -860,7 +969,7 @@ impl Clone for Group { Self { state: self.state.clone(), subscription: self.subscription.clone(), - cap: self.cap.clone(), + anchor: self.anchor.clone(), sequence: self.sequence, index: self.index, end: self.end, @@ -875,14 +984,14 @@ impl Group { fn new( state: kio::Consumer, subscription: kio::Consumer, - cap: kio::Consumer>, + anchor: kio::Consumer, sequence: u64, index: u64, ) -> Self { Self { state, subscription, - cap, + anchor, sequence, index, end: None, @@ -1075,7 +1184,7 @@ impl Group { // `start_at` clamps up to the first frame the copy still holds, so landing // higher than asked means this route can't cover the seam after all. Treat it // like a dead copy and wait for one that can. - let mut group = track.guard_group(group, self.subscription.clone(), self.cap.clone(), bound); + let mut group = track.guard_group(group, self.subscription.clone(), self.anchor.clone(), bound); group.set_stale_meter(self.stale_stats.clone()); group.start_at(self.index); if group.index() != self.index { @@ -1234,7 +1343,7 @@ impl Group { // already includes the frames it skipped. Some(continuation) => { let mut continuation = - track.guard_group(continuation, self.subscription.clone(), self.cap.clone(), bound); + track.guard_group(continuation, self.subscription.clone(), self.anchor.clone(), bound); continuation.set_stale_meter(self.stale_stats.clone()); return continuation.poll_finished(waiter); } @@ -1250,10 +1359,15 @@ struct SegmentSub { id: u64, start: Option, end: Option, + /// Where the source is asked to start; see [`Segment::ask`]. + ask: Option, sub: SubState, /// A completed segment's cursor, retained while parked groups may need their /// max age budget re-evaluated after the outer cap rises. terminal: Option, + /// The drift anchor for this segment's cursor as of the last + /// [`Subscriber::refresh_anchor`], applied when a pending cursor activates. + anchor: Anchor, /// The producer dropped this segment (pruned, or replaced before producing). /// The cursor drains what it already holds, then retires; see /// [`Self::retired`]. @@ -1264,6 +1378,16 @@ struct SegmentSub { /// the lowest is re-offered first; holding them here (rather than blocking on /// the first) keeps in-range groups that arrive behind a capped one flowing. parked: BTreeMap, + /// Set while this is a warm segment (see [`Producer::park`]) that has not been + /// cleared for live reads: the copy spliced after it, once there is one. + warm: Option, +} + +/// A warm segment waiting on the copy spliced after it; see [`Subscriber::poll_activate`]. +struct Warm { + /// The cache's newest group. + edge: Option, + next: Option, } impl SegmentSub { @@ -1364,14 +1488,15 @@ pub struct Subscriber { min_sequence: u64, /// Exclusive cap for [`Self::next_group`], set by [`Self::end_at`]. end_sequence: Option, - /// A cap imposed by a reader wrapping this subscriber (a nested splice segment), - /// folded into every drift anchor pushed onto the segments. Never bounds delivery: - /// the outer reader enforces its own window, and an inner cap would hide a - /// segment's completion from it. - stale_cap: Option, - /// Shared copy of the effective cap ([`Self::end_sequence`] and [`Self::stale_cap`] - /// combined) for groups that outlive this cursor poll. - drift_cap: kio::Producer>, + /// The anchor imposed by a reader wrapping this subscriber (a nested splice + /// segment), folded into every drift anchor pushed onto the segments. Its cap never + /// bounds delivery: the outer reader enforces its own window, and an inner cap would + /// hide a segment's completion from it. + outer: Anchor, + /// The logical drift anchor ([`Self::end_sequence`] and [`Self::outer`] combined, + /// with the newest edge across the segments), shared with groups that outlive this + /// cursor poll. Refreshed on every [`Self::poll_sync`]. + drift_anchor: kio::Producer, } impl Subscriber { @@ -1380,6 +1505,7 @@ impl Subscriber { fn poll_sync(&mut self, waiter: &kio::Waiter) { self.sync(waiter); self.reap(); + self.refresh_anchor(); } /// Reap retired cursors, then bound the live stragglers: a pruned segment's @@ -1432,7 +1558,7 @@ impl Subscriber { }; self.last_prefs = prefs; for seg in &mut self.segments { - let prefs = slice(&self.last_prefs, seg.start, seg.end); + let prefs = slice(&self.last_prefs, seg.ask, seg.end); if let Some(sub) = seg.stale_sub_mut() { let _ = sub.update(prefs); } @@ -1498,21 +1624,24 @@ impl Subscriber { } self.segments.retain(|s| !s.retired()); - let anchor = self.anchor_end(); - for segment in segments { + let nexts: Vec<_> = segments.iter().skip(1).map(|next| Some(next.track.clone())).collect(); + let nexts = nexts.into_iter().chain(std::iter::once(None)); + for (segment, next) in segments.into_iter().zip(nexts) { match self.segments.iter_mut().find(|s| s.id == segment.id) { Some(existing) => { + if let Some(warm) = &mut existing.warm { + warm.next = next; + } if existing.end != segment.end { + // The boundary bounds the drift anchor as well as the demand; the + // anchor follows in `refresh_anchor`. existing.end = segment.end; - let cap = Self::stale_cap(existing, anchor); if let Some(sub) = existing.stale_sub_mut() { - // The boundary bounds the drift anchor as well as the demand. - sub.set_stale_cap(cap); // Shrink the demand so the session can cap upstream. The // read bounds stay on this subscriber (see `poll_recv_group`): // an inner `end_at` would park boundary-crossing groups in the // inner cursor, hiding the segment's completion. - let _ = sub.update(slice(&self.last_prefs, segment.start, segment.end)); + let _ = sub.update(slice(&self.last_prefs, segment.ask, segment.end)); } // A still-pending subscription picks the moved boundary up // when it activates (see `poll_activate`). Groups already handed @@ -1523,15 +1652,21 @@ impl Subscriber { None => { let sub = segment .track - .subscribe(slice(&self.last_prefs, segment.start, segment.end)); + .subscribe(slice(&self.last_prefs, segment.ask, segment.end)); self.segments.push(SegmentSub { id: segment.id, start: segment.start, end: segment.end, + ask: segment.ask, sub: SubState::Pending(sub), terminal: None, + anchor: Anchor::default(), pruned: false, parked: BTreeMap::new(), + warm: segment.warm.then(|| Warm { + edge: segment.track.latest(), + next, + }), }); } } @@ -1559,7 +1694,7 @@ impl Subscriber { let spliced = Group::new( self.state.clone(), self.prefs.consume(), - self.drift_cap.consume(), + self.drift_anchor.consume(), sequence, 0, ) @@ -1567,41 +1702,75 @@ impl Subscriber { Some(group.into_spliced(spliced)) } - /// The highest sequence a segment could hand its reader: the reader's own cap and - /// the segment's boundary, whichever is lower. + /// The anchor a segment's cursor measures drift against: the logical `anchor`, with + /// its cap lowered to the segment's boundary. /// /// A segment's inner cursor is deliberately left uncapped (an inner `end_at` would /// park boundary-crossing groups where its completion can't be seen), so this is how /// both bounds reach the drift anchor. Without them a segment measures staleness /// against groups it will never surface: the route running past the boundary, or the - /// reader's own cap holding content back. `anchor_end` is [`Self::anchor_end`], so a - /// cap imposed on this subscriber from outside is included. - fn stale_cap(seg: &SegmentSub, anchor_end: Option) -> Option { - min_some(anchor_end, seg.last_group()) + /// reader's own cap holding content back. The edge is left as is: it is the newest + /// content the logical track holds, which a segment's own track never sees. + /// + /// When the boundary lowers the cap, the reader's next group past it lives in a + /// later segment, so `state` supplies where it starts ([`Anchor::successor`]). + /// Otherwise the logical anchor's own successor (a wrapping splice's) still holds. + fn segment_anchor(seg: &SegmentSub, anchor: Anchor, state: &ResumeState) -> Anchor { + let Some(boundary) = seg.last_group() else { + return anchor; + }; + let cap = anchor.cap; + let mut capped = anchor.capped(Some(boundary)); + if capped.cap != cap { + capped.successor = state.successor(seg.id, boundary, cap); + } + capped } - /// The tightest cap any reader of this subscriber imposes: its own [`Self::end_at`] - /// and whatever a wrapping splice pushed down. What every segment's drift anchor is - /// bounded by. - fn anchor_end(&self) -> Option { - min_some(self.stale_cap, self.end_sequence) + /// The logical drift anchor, as of the last [`Self::refresh_anchor`]. + fn anchor(&self) -> Anchor { + self.drift_anchor.read().clone() } - /// Bound every segment's drift anchor from outside; the spliced arm of - /// [`track::Subscriber::set_stale_cap`], for this subscriber nested as a segment of - /// another splice. - pub(crate) fn set_stale_cap(&mut self, cap: Option) { - self.stale_cap = cap; - self.update_drift_cap(); - let anchor = self.anchor_end(); + /// Re-derive the logical drift anchor and push it onto every segment cursor. + /// + /// The cap is the tightest any reader of this subscriber imposes: its own + /// [`Self::end_at`] and whatever a wrapping splice pushed down. The edge is the + /// newest across the producer's segments within that cap, or a wrapping splice's if + /// newer. Resolved on every sync, since each segment is a separate track and the + /// logical edge moves whenever any of them grows. + fn refresh_anchor(&mut self) { + let outer = self.outer.clone().capped(self.end_sequence); + let state = self.state.read(); + let edge = state + .live_edge(outer.cap) + .into_iter() + .chain(outer.edge.clone()) + .max_by_key(|edge| edge.sequence); + let anchor = Anchor { edge, ..outer }; + // Skip a no-op write: every handed-out group's expiry watches this channel. + if self.anchor() != anchor + && let Ok(mut current) = self.drift_anchor.write() + { + *current = anchor.clone(); + } for seg in &mut self.segments { - let cap = Self::stale_cap(seg, anchor); + let anchor = Self::segment_anchor(seg, anchor.clone(), &state); + seg.anchor = anchor.clone(); if let Some(sub) = seg.stale_sub_mut() { - sub.set_stale_cap(cap); + sub.set_anchor(anchor); } } } + /// Bound every segment's drift anchor from outside; the spliced arm of + /// [`track::Subscriber::set_anchor`], for this subscriber nested as a segment of + /// another splice. + pub(crate) fn set_anchor(&mut self, anchor: Anchor) { + self.outer = anchor; + self.refresh_anchor(); + } + /// Count each segment's seek convictions behind the deliverer's `committed` /// watermark; the spliced arm of [`track::Subscriber::commit_seek_stale`]. pub(crate) fn commit_seek_stale(&mut self, committed: u64) { @@ -1640,23 +1809,29 @@ impl Subscriber { Poll::Ready(Ok(false)) } - /// Publish the effective cap for groups that outlive this cursor poll. - fn update_drift_cap(&mut self) { - if let Ok(mut cap) = self.drift_cap.write() { - *cap = self.anchor_end(); - } - } - /// Resolve a segment's pending subscription, if any. Ready once the segment is /// `Active` or `Done`; a rejected or closed track becomes `Done` (stall, not /// error). Never consumes groups, so terminal-state pollers can share it. - fn poll_activate( - seg: &mut SegmentSub, - prefs: &Subscription, - min_sequence: u64, - anchor_end: Option, - waiter: &kio::Waiter, - ) -> Poll<()> { + fn poll_activate(seg: &mut SegmentSub, prefs: &Subscription, min_sequence: u64, waiter: &kio::Waiter) -> Poll<()> { + if matches!(seg.sub, SubState::Pending(_)) + && let Some(warm) = &seg.warm + { + // Nothing spliced after the cache yet, so nothing says it still leads into + // the live feed. A splice bumps the epoch, which wakes this waiter. + let Some(next) = &warm.next else { + return Poll::Pending; + }; + let start = ready!(next.poll_start(waiter)); + let edge = warm.edge; + seg.warm = None; + // The copy was asked for the cache's newest group and started past it: its + // source judged that group stale, so the older cache is no use to live reads. + if start.is_some_and(|start| edge.is_some_and(|edge| start > edge)) { + seg.complete(None); + return Poll::Ready(()); + } + } + if let SubState::Pending(pending) = &mut seg.sub { match ready!(pending.poll_ok(waiter)) { Ok(mut sub) => { @@ -1668,8 +1843,8 @@ impl Subscriber { // assigned: the inner subscription resolved its own start from its // budget and floor, and this must not rewind past it. sub.raise_start_to(seg.first_group().max(min_sequence)); - sub.set_stale_cap(Self::stale_cap(seg, anchor_end)); - let _ = sub.update(slice(prefs, seg.start, seg.end)); + sub.set_anchor(seg.anchor.clone()); + let _ = sub.update(slice(prefs, seg.ask, seg.end)); seg.sub = SubState::Active(Box::new(sub)); } // The underlying track was rejected or closed: stall, not error. @@ -1685,13 +1860,12 @@ impl Subscriber { seg: &mut SegmentSub, prefs: &Subscription, min_sequence: u64, - anchor_end: Option, waiter: &kio::Waiter, ) -> Poll> { loop { match &mut seg.sub { SubState::Pending(_) => { - ready!(Self::poll_activate(seg, prefs, min_sequence, anchor_end, waiter)); + ready!(Self::poll_activate(seg, prefs, min_sequence, waiter)); } SubState::Active(sub) => match ready!(sub.poll_recv_group(waiter)) { Ok(Some(group)) => { @@ -1757,7 +1931,6 @@ impl Subscriber { self.commit_seek_stale(committed); } - let anchor = self.anchor_end(); let mut floor = floor; 'retry: loop { let mut all_done = true; @@ -1765,14 +1938,8 @@ impl Subscriber { for index in 0..self.segments.len() { if matches!(self.segments[index].sub, SubState::Pending(_)) - && Self::poll_activate( - &mut self.segments[index], - &self.last_prefs, - self.min_sequence, - anchor, - waiter, - ) - .is_pending() + && Self::poll_activate(&mut self.segments[index], &self.last_prefs, self.min_sequence, waiter) + .is_pending() { all_done = false; continue; @@ -1864,7 +2031,6 @@ impl Subscriber { self.poll_sync(waiter); let end_sequence = self.end_sequence; - let anchor = self.anchor_end(); let min_sequence = self.min_sequence; let beyond_cap = |sequence: u64| !super::subscription::before_end(sequence, end_sequence); @@ -1919,13 +2085,7 @@ impl Subscriber { } loop { - let polled = Self::poll_segment( - &mut self.segments[index], - &self.last_prefs, - min_sequence, - anchor, - waiter, - ); + let polled = Self::poll_segment(&mut self.segments[index], &self.last_prefs, min_sequence, waiter); match polled { Poll::Ready(Some(group)) => { if beyond_cap(group.sequence) { @@ -2040,9 +2200,8 @@ impl Subscriber { // datagrams must still resolve the subscription (registering demand) and // be woken when it activates. let mut pending_activation = false; - let anchor = self.anchor_end(); if let Some(seg) = self.segments.last_mut() { - if Self::poll_activate(seg, &self.last_prefs, self.min_sequence, anchor, waiter).is_pending() { + if Self::poll_activate(seg, &self.last_prefs, self.min_sequence, waiter).is_pending() { pending_activation = true; } else if let SubState::Active(sub) = &mut seg.sub && let Ok(Some(datagram)) = ready!(sub.poll_recv_datagram(waiter)) @@ -2101,17 +2260,10 @@ impl Subscriber { /// completing the segment, would steal them from a `recv_group` caller on the /// same subscriber. fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll> { - let anchor = min_some(self.stale_cap, self.end_sequence); let Some(seg) = self.segments.last_mut() else { return Poll::Ready(None); }; - ready!(Self::poll_activate( - seg, - &self.last_prefs, - self.min_sequence, - anchor, - waiter - )); + ready!(Self::poll_activate(seg, &self.last_prefs, self.min_sequence, waiter)); match &mut seg.sub { SubState::Done(count) => Poll::Ready(*count), // Observe only: the cursor may still hold groups, so the read path @@ -2167,16 +2319,9 @@ impl Subscriber { /// re-offers it. pub fn end_at(&mut self, end: impl Into) { self.end_sequence = end.into().exclusive(); - self.update_drift_cap(); // The cap bounds each segment's drift anchor as well as this reader's own // delivery: a segment must not measure against groups this cap hides. - let anchor = self.anchor_end(); - for seg in &mut self.segments { - let cap = Self::stale_cap(seg, anchor); - if let Some(sub) = seg.stale_sub_mut() { - sub.set_stale_cap(cap); - } - } + self.refresh_anchor(); } /// The shared preferences channel, so `track::Control` can wrap it. @@ -2221,7 +2366,7 @@ impl Subscriber { #[cfg(test)] mod test { use super::*; - use crate::{Timestamp, broadcast}; + use crate::broadcast; use futures::FutureExt; use std::sync::Arc; use std::time::Duration; @@ -2591,7 +2736,7 @@ mod test { let mut producer = Producer::new(); producer.switch(&consumer_a, None).unwrap(); - let mut sub = producer.consume().subscribe(None); + let mut sub = producer.consume().subscribe(replay()); write_group(&mut track_a, 0, "a0"); producer.switch(&consumer_b, Position::group(1)).unwrap(); @@ -3024,9 +3169,11 @@ mod test { "the arrival path skips straight to the live edge" ); - // Spliced: the same backlog split across a takeover boundary. - let (mut track_a, consumer_a) = track_pair("a"); - let (mut track_b, consumer_b) = track_pair("b"); + // Spliced: the same backlog split across a takeover boundary. Retained long + // enough that the replay budget below is not clamped to the default window. + let retain = track::Info::default().with_max_age(Duration::from_secs(60)); + let (mut track_a, consumer_a) = track_pair_with("a", retain.clone()); + let (mut track_b, consumer_b) = track_pair_with("b", retain); let mut producer = Producer::new(); producer.switch(&consumer_a, None).unwrap(); producer.switch(&consumer_b, Position::group(2)).unwrap(); @@ -3045,11 +3192,11 @@ mod test { }) .collect(); - // Group 1 survives where the plain cursor drops it: each segment measures drift - // against its own boundary, since a segment cannot serve content past it, and - // group 1 is the newest thing segment A has. The sequence path inherits that - // from the arrival path rather than inventing its own anchor, so the two agree. - assert_eq!(spliced, vec![1, 3], "each segment is judged within its own boundary"); + // Segment A's groups are judged as the plain cursor judges them: against the + // logical edge (group 3), with group 1's reach bounded by its successor in + // segment B. The sequence path inherits that from the arrival path rather than + // inventing its own anchor, so the two agree. + assert_eq!(spliced, baseline, "a splice sheds the backlog like one track"); let mut arrival = producer.consume().subscribe(None); let arrival: Vec = std::iter::from_fn(|| { @@ -3075,38 +3222,101 @@ mod test { assert_eq!(replayed, vec![0, 1, 2, 3], "a backlog inside the budget crosses whole"); } + /// A segment's track never sees the groups of the segments after it: its own edge + /// freezes once a takeover caps it, and its last group has no successor there. + /// Both spliced cursors judge its backlog as one plain track holding the same groups + /// would: against the logical edge, with the last group's reach bounded by where the + /// next segment picks up. + #[tokio::test] + async fn a_capped_segment_is_judged_like_one_track() { + let budget = Subscription::default().with_max_age(Duration::from_millis(100)); + let old = [(0, 0), (1, 20), (2, 40), (3, 60)]; + let new = [(20, 400), (21, 420), (22, 440), (23, 460), (24, 480), (25, 500)]; + + let (mut plain, _plain_consumer) = track_pair("plain"); + for (sequence, millis) in old.into_iter().chain(new) { + write_group_at(&mut plain, sequence, "p", Duration::from_millis(millis)); + } + let mut baseline = plain.subscribe(budget.clone()); + let baseline: Vec = std::iter::from_fn(|| { + baseline + .recv_group() + .now_or_never()? + .expect("should not error") + .map(|group| group.sequence) + }) + .collect(); + // Group 3 reaches group 20's start at 400ms, 100ms behind the edge at 500ms. + assert_eq!(baseline, vec![20, 21, 22, 23, 24, 25]); + + let (mut track_a, consumer_a) = track_pair("a"); + let (mut track_b, consumer_b) = track_pair("b"); + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + producer.switch(&consumer_b, Position::group(4)).unwrap(); + for (sequence, millis) in old { + write_group_at(&mut track_a, sequence, "a", Duration::from_millis(millis)); + } + for (sequence, millis) in new { + write_group_at(&mut track_b, sequence, "b", Duration::from_millis(millis)); + } + + let mut arrival = producer.consume().subscribe(budget.clone()); + let arrival: Vec = std::iter::from_fn(|| { + kio::wait(|waiter| arrival.poll_recv_group(waiter)) + .now_or_never()? + .expect("should not error") + .map(|group| group.sequence) + }) + .collect(); + assert_eq!(arrival, baseline); + + let mut ordered = producer.consume().subscribe(budget); + let ordered: Vec = std::iter::from_fn(|| { + kio::wait(|waiter| ordered.poll_next_group(waiter)) + .now_or_never()? + .expect("should not error") + .map(|group| group.sequence) + }) + .collect(); + assert_eq!(ordered, baseline); + } + /// A nested splice's leaves are judged within the *outer* boundary. The outer /// window reaches them two ways, through the seek's own `end` and through - /// [`track::Subscriber::set_stale_cap`] recursing into the spliced segment; without + /// [`track::Subscriber::set_anchor`] recursing into the spliced segment; without /// either, a leaf anchors drift on a group past the outer boundary and convicts /// everything the outer segment still owes its reader. #[tokio::test] async fn nested_splice_judges_within_the_outer_boundary() { let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + let retain = track::Info::default().with_max_age(Duration::from_secs(60)); + let budget = Subscription::default().with_max_age(Duration::from_secs(15)); // Inner splice: one segment carrying groups 0..=2. Group 2 sits past the - // outer boundary below, so it is exactly the anchor the outer window must - // hide from the inner leaves. - let (mut track_a, consumer_a) = track_pair("a"); + // outer boundary below and rewinds to 0s, so it is exactly the successor the + // outer window must hide from the inner leaves: taken as group 1's, it would + // collapse group 1's reach to 0s, 30s behind the edge. + let (mut track_a, consumer_a) = track_pair_with("a", retain.clone()); let mut inner = Producer::new(); inner.switch(&consumer_a, None).unwrap(); write_group_at(&mut track_a, 0, "a0", stamp(0)); write_group_at(&mut track_a, 1, "a1", stamp(1)); - write_group_at(&mut track_a, 2, "a2", stamp(2)); + write_group_at(&mut track_a, 2, "a2", stamp(0)); // Outer splice: the inner spliced track up to group 2, then a plain track. let inner_track = track::Consumer::spliced("inner".into(), Arc::new(broadcast::Info::default()), inner.consume()); - let (mut track_b, consumer_b) = track_pair("b"); + let (mut track_b, consumer_b) = track_pair_with("b", retain); let mut outer = Producer::new(); outer.switch(&inner_track, None).unwrap(); outer.switch(&consumer_b, Position::group(2)).unwrap(); write_group_at(&mut track_b, 2, "b2", stamp(2)); write_group_at(&mut track_b, 3, "b3", stamp(3)); - // Group 1 is the newest group the outer window can serve from the nested - // segment, so it is its own live edge there and survives a zero budget. - let mut sub = outer.consume().subscribe(None); + // Group 0 is 20s behind the edge, past the 15s budget. Group 1 reaches B's + // group 2 at 20s, only 10s behind, so it survives. + let mut sub = outer.consume().subscribe(budget.clone()); let sequences: Vec = std::iter::from_fn(|| { kio::wait(|waiter| sub.poll_next_group(waiter)) .now_or_never()? @@ -3116,11 +3326,11 @@ mod test { .collect(); assert_eq!( sequences, - vec![1, 3], + vec![1, 2, 3], "the nested segment is judged within the outer boundary" ); - let mut arrival = outer.consume().subscribe(None); + let mut arrival = outer.consume().subscribe(budget); let arrival: Vec = std::iter::from_fn(|| { kio::wait(|waiter| arrival.poll_recv_group(waiter)) .now_or_never()? @@ -3137,7 +3347,9 @@ mod test { /// the group after all, and delivered content never counts. #[tokio::test] async fn seek_conviction_counts_only_once_committed() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 2 starts after group 3 (a rewind), so group 1's reach runs past the live + // edge and it survives a zero budget, while groups 0 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 10, 40, 30][sequence as usize]); let (mut track_a, consumer_a) = track_pair("a"); let (mut track_b, consumer_b) = track_pair("b"); @@ -3182,7 +3394,9 @@ mod test { /// jumped past it, and still be deliverable (uncounted) once the budget widens. #[tokio::test] async fn a_reversible_floor_does_not_commit_a_conviction() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 2 starts after group 3 (a rewind), so group 1's reach runs past the live + // edge and it survives a zero budget, while groups 0 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 10, 40, 30][sequence as usize]); let (mut track_a, consumer_a) = track_pair("a"); let (mut track_b, consumer_b) = track_pair("b"); @@ -3231,7 +3445,9 @@ mod test { /// that content, so the conviction must be discarded rather than counted. #[tokio::test] async fn a_delivered_continuation_is_not_counted_stale() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 2 starts after group 3 (a rewind), so group 1's reach runs past the live + // edge and it survives a zero budget, while groups 0 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 10, 40, 30][sequence as usize]); let (mut track_a, consumer_a) = track_pair("a"); let (mut track_b, consumer_b) = track_pair("b"); @@ -3255,9 +3471,9 @@ mod test { .sequence }; - // Group 1 wins from segment A (it holds the head copy) while segment B has - // convicted both its continuation copy of 1 and group 2. Delivering 1 must - // discard B's copy, not count it. + // Group 1 wins from segment A (it holds the head copy) while segment B convicts + // group 2. B's continuation copy of 1 is delivered through the head, so it + // must not be counted. assert_eq!(next(&mut sub), 1); assert_eq!(next(&mut sub), 3); assert_eq!( @@ -3274,12 +3490,16 @@ mod test { /// commit may count; a conviction never committed is dropped with the cursor. #[tokio::test] async fn a_finalized_segment_flushes_nothing_without_a_delivery() { - let stamp = |sequence: u64| Duration::from_secs(10 * sequence); + // Group 1 starts after group 3 (a rewind), so group 0's reach runs past the live + // edge and it survives a zero budget, while groups 1 and 2 are convicted. + let stamp = |sequence: u64| Duration::from_secs([0, 40, 20, 30][sequence as usize]); // C owns group 0, A owns group 1's head, finalized B owns its tail onward. - let (mut track_c, consumer_c) = track_pair("c"); - let (mut track_a, consumer_a) = track_pair("a"); - let (mut track_b, consumer_b) = track_pair("b"); + // Retained long enough that the widened budget below is not clamped. + let retain = track::Info::default().with_max_age(Duration::from_secs(60)); + let (mut track_c, consumer_c) = track_pair_with("c", retain.clone()); + let (mut track_a, consumer_a) = track_pair_with("a", retain.clone()); + let (mut track_b, consumer_b) = track_pair_with("b", retain); let mut producer = Producer::new(); producer.switch(&consumer_c, None).unwrap(); producer.switch(&consumer_a, Position::group(1)).unwrap(); @@ -4171,7 +4391,7 @@ mod test { #[tokio::test] async fn capped_subscriber_bounds_parked_segments() { let mut producer = Producer::new(); - let mut sub = producer.consume().subscribe(None); + let mut sub = producer.consume().subscribe(replay()); sub.end_at(..1); // Every round parks one group beyond the cap, then fails over to a live @@ -4980,8 +5200,8 @@ mod test { let mut producer = Producer::new(); producer.switch(&consumer_a, None).unwrap(); let consumer = producer.consume(); - let mut sub1 = consumer.subscribe(None); - let mut sub2 = consumer.subscribe(None); + let mut sub1 = consumer.subscribe(replay()); + let mut sub2 = consumer.subscribe(replay()); recv_pending(&mut sub1); recv_pending(&mut sub2); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 0da0c4a157..7d8c4a87e1 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -218,11 +218,21 @@ pub(crate) struct TrackState { // The sequence number at which the track was finalized. final_sequence: Option, + // The last producer dropped after the boundary was declared, so a group still missing + // below it will never be produced. + sealed: 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. start_sequence: Option, + // Whether `start_sequence` is only the floor a subscription asked for, still + // waiting on the serving session to resolve where the live feed begins (a + // lite-06+ SUBSCRIBE_START). Readers that must know the resolved start (see + // [`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 // abort, or the last producer dropping). Computed live from the cache otherwise; // see [`Self::resume_position`]. @@ -431,9 +441,11 @@ impl TrackState { } // `final_sequence` is one past the last possible sequence. If our // floor is already at/past it, nothing else can land in range. - if let Some(fin) = self.final_sequence - && next_sequence >= fin - { + // 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. + // 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)); } Poll::Pending @@ -471,9 +483,8 @@ impl TrackState { /// it, so the groups it still wants aren't stale just because the route ran on. /// Fetched backfill is absent from `arrival`, so it cannot age subscription content /// as though it were a live replacement. - fn live_edge(&self, cap: Option) -> Option { - let presentation = self - .lookup + fn live_edge(&self, cap: Option) -> Option { + self.lookup .range(..) .rev() .filter(|(seq, _)| super::subscription::before_end(**seq, cap)) @@ -489,9 +500,26 @@ impl TrackState { stamp: slot.stamp, timestamp: slot.group.latest().unwrap_or(timestamp), }) - }); + }) + } + + /// This track's own edge under the exclusive `cap`, for measuring drift. An outer + /// edge and a successor live on other tracks; the caller revalidates those before + /// taking this lock and passes them in, so the locks never nest. + fn drift_edge(&self, cap: Option, outer: Option<(u64, Timestamp)>, successor: Option) -> Edge { + Edge { + presentation: self.live_edge(cap), + outer, + cap, + successor, + } + } - presentation.map(|presentation| Edge { presentation, cap }) + /// Whether `sequence` still holds the servable incarnation `stamp`. + fn holds(&self, sequence: u64, stamp: u32) -> bool { + self.lookup + .get(&sequence) + .is_some_and(|slot| slot.stamp == stamp && !slot.group.is_aborted()) } /// The furthest presentation time the group at `sequence` could still reach: where @@ -504,14 +532,45 @@ impl TrackState { /// so a later stamped group proves nothing about where an unstamped successor will /// begin, and shrinking the bound is the unsafe direction. An unstamped successor /// therefore leaves the reach unbounded until it presents its first frame. - fn reach(&self, sequence: u64, cap: Option) -> Option { - let successor = self + /// + /// With no servable successor below `cap`, the reader's next group is past the cap, + /// and `beyond` is where it starts when another track serves it (a splice's next + /// segment; see [`Anchor::successor`]). + fn reach(&self, sequence: u64, cap: Option, beyond: Option) -> Option { + match self.first_start(sequence.saturating_add(1), cap) { + Some(start) => start, + None => beyond, + } + } + + /// Where the first servable group in `from..cap` starts presenting: `None` when no + /// such group is cached, `Some(None)` while it has no frame yet. + fn first_start(&self, from: u64, cap: Option) -> Option> { + let slot = self + .lookup + .range(from..) + .map(|(_, slot)| slot) + .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) + .find(|slot| slot.visible && !slot.group.is_aborted())?; + Some(slot.group.timestamp()) + } + + /// The first servable group's start in `from..cap`, with the slot identity a later + /// judgment needs to tell that group from whatever replaces it. `None` when no such + /// group is cached or it has no frame yet: an unstamped successor leaves reach + /// unbounded, and this does not skip past it to a later group. + fn served_start(&self, from: u64, cap: Option) -> Option { + let slot = self .lookup - .range(sequence.saturating_add(1)..) + .range(from..) .map(|(_, slot)| slot) .take_while(|slot| super::subscription::before_end(slot.group.sequence, cap)) .find(|slot| slot.visible && !slot.group.is_aborted())?; - successor.group.timestamp() + Some(ServedStart { + sequence: slot.group.sequence, + stamp: slot.stamp, + timestamp: slot.group.timestamp()?, + }) } /// Whether the group at `sequence` has drifted further behind `edge` than `budget` @@ -537,11 +596,10 @@ impl TrackState { /// /// The edge must sit strictly above the candidate. The live edge is never late /// against itself, and backfill or the tail of a rewound timeline can carry a high - /// timestamp on a low sequence without being an edge at all. - fn is_stale(&self, sequence: u64, edge: Option<&Edge>, budget: Duration) -> bool { - let Some(edge) = edge else { - return false; - }; + /// timestamp on a low sequence without being an edge at all. Of the edges that + /// qualify, the highest sequence is the newest content, whichever track holds it: + /// this one's, or the `outer` edge a splice pushed from another segment. + fn is_stale(&self, sequence: u64, edge: &Edge, budget: Duration) -> bool { if !self.lookup.contains_key(&sequence) { return false; } @@ -549,16 +607,23 @@ impl TrackState { // The anchor was resolved under an earlier lock, so confirm it still names // the same servable incarnation before it convicts a candidate. Failing safe // (delivering) is right, since the next poll resolves fresh anchors. - let live_edge = &edge.presentation; - let reach = self.reach(sequence, edge.cap); - live_edge.sequence > sequence - && self - .lookup - .get(&live_edge.sequence) - .is_some_and(|live| live.stamp == live_edge.stamp && !live.group.is_aborted()) - && reach.is_some_and( - |reach| matches!(live_edge.timestamp.checked_sub(reach), Ok(age) if Duration::from(age) >= budget), - ) + let local = edge + .presentation + .filter(|live| self.holds(live.sequence, live.stamp)) + .map(|live| (live.sequence, live.timestamp)); + // `outer` and `successor` were revalidated on their own tracks before this lock + // was taken ([`LiveEdge::is_live`], [`Successor::start`]). There is no slot for + // them here, and taking their locks here would nest. + let Some((_, timestamp)) = local + .into_iter() + .chain(edge.outer) + .filter(|(live, _)| *live > sequence) + .max_by_key(|(live, _)| *live) + else { + return false; + }; + self.reach(sequence, edge.cap, edge.successor) + .is_some_and(|reach| matches!(timestamp.checked_sub(reach), Ok(age) if Duration::from(age) >= budget)) } /// Resolve a one-shot fetch from the track side: the cached group, or an [`Error`] @@ -986,8 +1051,9 @@ impl TrackState { /// declaration: the signal is scoped to the current subscription's demand, /// which may legitimately move in either direction. `None` clears it (the /// demand dropped to the live edge, whose floor is unknown until declared). - fn set_start(&mut self, start_sequence: Option) { + fn set_start(&mut self, start_sequence: Option, pending: bool) { self.start_sequence = start_sequence; + self.start_pending = pending; } /// Record the exclusive final sequence, rejecting a re-finish or a boundary that @@ -1008,11 +1074,12 @@ impl TrackState { /// Whether the track has reached its end: the final boundary is set and the live /// edge has caught up to it, so no further group can arrive. A future boundary /// (declared via [`Producer::finish_at`] ahead of the live edge) stays incomplete - /// until the remaining groups are produced. Drives the end-of-stream signal from + /// until the remaining groups are produced, or until the last producer drops without + /// them. Drives the end-of-stream signal from /// the read methods (`recv_group` / `next_group` / `read_frame` return `None`). fn is_complete(&self) -> bool { self.final_sequence - .is_some_and(|fin| self.max_sequence.map_or(0, |max| max.saturating_add(1)) >= fin) + .is_some_and(|fin| self.sealed || self.max_sequence.map_or(0, |max| max.saturating_add(1)) >= fin) } /// Where a replacement route should pick this track up: one past the last frame @@ -1381,7 +1448,16 @@ impl Producer { /// still promised. Pass `None` to clear it, for demand at the live edge: /// its floor is unknown until the feed declares one. pub fn start_at(&mut self, sequence: impl Into>) -> Result<()> { - self.modify()?.set_start(sequence.into()); + self.modify()?.set_start(sequence.into(), false); + Ok(()) + } + + /// Declare the floor a subscription asked for while the serving session has yet to + /// resolve its start: nothing below `sequence` arrives, exactly as [`Self::start_at`], + /// but [`Consumer::poll_start`] keeps waiting until a later [`Self::start_at`] + /// resolves it. + pub(crate) fn request_start(&mut self, sequence: Option) -> Result<()> { + self.modify()?.set_start(sequence, true); Ok(()) } @@ -1538,7 +1614,7 @@ impl Producer { let min_sequence = floor_of(&preferences); let subscription = kio::Producer::new(preferences); register_subscription(self.state.read(), &subscription); - let drift_cap = kio::Producer::new(None); + let drift_anchor = kio::Producer::new(Anchor::default()); // Hoisted: an inline `read()` guard would live to the end of the struct literal, // deadlocking against the `consume()` below. @@ -1557,7 +1633,7 @@ impl Producer { end_sequence: None, parked: BTreeMap::new(), stale_cap: None, - drift_cap, + drift_anchor, stale: stats::Content::default(), seek_pending: BTreeMap::new(), }), @@ -1830,7 +1906,13 @@ impl Drop for Alive { // leaves it open with `final_sequence` set, so inspect both outcomes. match self.state.write() { Ok(mut state) => { - if state.final_sequence.is_some() || state.abort.is_some() { + if state.final_sequence.is_some() { + // 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; + return; + } + if state.abort.is_some() { return; } tracing::warn!( @@ -2309,6 +2391,28 @@ impl Consumer { }) } + /// Poll for the first group the live feed serves, once the serving session has + /// resolved it: `Some` for a declared start, `None` for none (the live edge, or a + /// source that never declares one). Parks while a lite-06+ session still owes its + /// SUBSCRIBE_START (see [`Producer::request_start`]); a closed track is ready with + /// 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::Spliced(_) => Poll::Ready(None), + } + } + /// 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. @@ -2322,6 +2426,40 @@ impl Consumer { } } + /// The live edge below the exclusive `cap` that drift is measured against; see + /// [`TrackState::live_edge`]. A splice reports the newest across its segments. + pub(crate) fn live_edge(&self, cap: Option) -> Option { + match &self.inner { + ConsumerKind::Plain(state) => { + let edge = state.read().live_edge(cap)?; + Some(LiveEdge { + sequence: edge.sequence, + timestamp: edge.timestamp, + stamp: edge.stamp, + track: state.weak(), + }) + } + ConsumerKind::Spliced(resume) => resume.live_edge(cap), + } + } + + /// Where the first servable group in `from..cap` starts, with enough identity to + /// revalidate it later. A splice answers from the first segment holding one. + pub(crate) fn served_start(&self, from: u64, cap: Option) -> Option { + match &self.inner { + ConsumerKind::Plain(state) => { + let served = state.read().served_start(from, cap)?; + Some(Successor { + sequence: served.sequence, + timestamp: served.timestamp, + stamp: served.stamp, + track: state.weak(), + }) + } + ConsumerKind::Spliced(resume) => resume.served_start(from, cap), + } + } + /// The nearest cached group below `sequence`, under the same terms as /// [`Self::peek_group`]. Walks the cache's own order, so gaps in the group numbering /// are crossed and aborted (evicted) entries are skipped. @@ -2364,7 +2502,7 @@ impl Consumer { &self, group: group::Consumer, subscription: kio::Consumer, - cap: kio::Consumer>, + anchor: kio::Consumer, bound: Option, ) -> group::Consumer { let ConsumerKind::Plain(state) = &self.inner else { @@ -2374,7 +2512,7 @@ impl Consumer { group.with_expiry(Arc::new(GroupExpiry { state: state.weak(), subscription, - cap, + anchor, bound, sequence, })) @@ -2625,7 +2763,7 @@ impl Subscribing { let info = ready!(state.poll(waiter, |state| state.poll_info())) .map_err(|e| e.abort.clone().unwrap_or(Error::Dropped))??; - let drift_cap = kio::Producer::new(None); + let drift_anchor = kio::Producer::new(Anchor::default()); let min_sequence = floor_of(&self.subscription.read()); Poll::Ready(Ok(Subscriber { name: self.name.clone(), @@ -2641,7 +2779,7 @@ impl Subscribing { end_sequence: None, parked: BTreeMap::new(), stale_cap: None, - drift_cap, + drift_anchor, stale: stats::Content::default(), seek_pending: BTreeMap::new(), }), @@ -2931,11 +3069,14 @@ enum SubscriberKind { /// One poll's view of how far this subscription may drift: the clamped budget and the /// live edge to measure a candidate group against. Resolved once, then applied to every -/// group that poll considers. +/// group that poll considers. `outer` and `successor` are revalidated per candidate, +/// outside this track's lock. #[derive(Clone)] struct Drift { budget: Duration, - edge: Option, + edge: Edge, + outer: Option, + successor: Option, } /// Keeps one handed-out group tied to the subscription whose cursor selected it. @@ -2947,7 +3088,7 @@ struct GroupExpiry { /// the last real subscriber. state: kio::ConsumerWeak, subscription: kio::Consumer, - cap: kio::Consumer>, + anchor: kio::Consumer, bound: Option, sequence: u64, } @@ -2960,19 +3101,26 @@ impl group::Expiry for GroupExpiry { Poll::<()>::Pending }); - let mut cap = None; - let _ = self.cap.poll(waiter, |current| { - cap = **current; + let mut anchor = Anchor::default(); + let _ = self.anchor.poll(waiter, |current| { + anchor = (**current).clone(); Poll::<()>::Pending }); - let cap = super::subscription::min_some(cap, self.bound); + let anchor = anchor.capped(self.bound); + let cap = anchor.cap; + // Before this track's lock: both may name another track, and nesting deadlocks. + let outer = anchor + .edge + .filter(LiveEdge::is_live) + .map(|live| (live.sequence, live.timestamp)); + let successor = anchor.successor.as_ref().and_then(Successor::start); let mut expired = false; let _ = self.state.poll(waiter, |state| { let budget = clamp_max_age(max_age, state.max_age_bound()); loop { - let edge = state.live_edge(cap); - expired = state.is_stale(self.sequence, edge.as_ref(), budget); + let edge = state.drift_edge(cap, outer, successor); + expired = state.is_stale(self.sequence, &edge, budget); if expired { break; } @@ -3026,12 +3174,121 @@ impl group::Expiry for GroupExpiry { /// The group a poll's drift is measured against, identified well enough to tell it apart /// from whatever may occupy its sequence by the time a candidate is judged. -#[derive(Clone)] +#[derive(Clone, Copy)] struct Edge { - presentation: PresentationEdge, + /// This track's own edge, revalidated before it convicts anything. + presentation: Option, + /// A newer edge on another track, already revalidated: its sequence and the newest + /// frame it had presented. See [`Anchor::edge`]. + outer: Option<(u64, Timestamp)>, /// The cap the edge was resolved under, so per-candidate reach lookups measure /// against the same servable window. cap: Option, + /// Where the next group past `cap` starts, already revalidated. See [`Anchor::successor`]. + successor: Option, +} + +/// How a reader wrapping a cursor bounds its drift anchor from outside: pushed by a +/// splice onto each segment's cursor, and shared with the groups a cursor hands out. +#[derive(Clone, Default, PartialEq)] +pub(crate) struct Anchor { + /// The exclusive sequence cap on what the reader could be handed; see [`servable_cap`]. + pub cap: Option, + /// The newest edge across a splice's segments. Each segment is a separate track + /// that only sees its own groups, so without this a parked segment measures against + /// its own frozen edge while the logical track has moved on. Revalidated on its own + /// track before it convicts anything, since it may be judged long after it was pushed. + pub edge: Option, + /// Where the reader's next group past `cap` starts presenting, when another track + /// serves it (a splice's next segment). The last group below the cap has no + /// successor in its own track, so without this nothing bounds its reach and it is + /// never judged stale. `None` while unknown or unstamped. Revalidated like `edge`: + /// a cached start must not convict once that group is gone. + pub successor: Option, +} + +impl Anchor { + /// This anchor under a further `cap`. A lower cap drops the successor: it named + /// where the reader continues past the old cap, which is no longer served. + pub fn capped(mut self, cap: Option) -> Self { + let capped = servable_cap(cap, self.cap); + if capped != self.cap { + self.cap = capped; + self.successor = None; + } + self + } +} + +/// The newest stamped group of a track: its sequence and the newest frame it presented, +/// plus enough identity for a reader on another track to revalidate it. +#[derive(Clone)] +pub(crate) struct LiveEdge { + pub sequence: u64, + pub timestamp: Timestamp, + stamp: u32, + track: kio::ConsumerWeak, +} + +impl LiveEdge { + /// Whether the edge still names the same servable group on its own track, the + /// check [`TrackState::is_stale`] runs on a local edge. An eviction or abort since + /// the splice resolved it must not convict anything. Takes that track's lock, so + /// never call it under another's. + fn is_live(&self) -> bool { + self.track.read().holds(self.sequence, self.stamp) + } +} + +impl PartialEq for LiveEdge { + fn eq(&self, other: &Self) -> bool { + self.sequence == other.sequence + && self.timestamp == other.timestamp + && self.stamp == other.stamp + && self.track.same_channel(&other.track) + } +} + +/// The first servable group past a segment boundary: where it starts, and which slot +/// that start was read from. +struct ServedStart { + sequence: u64, + stamp: u32, + timestamp: Timestamp, +} + +/// A successor pushed onto another track's cursor. The timestamp alone is not enough: +/// once the group is evicted, a later group can keep the outer edge valid while this +/// start is no longer where the track continues. +#[derive(Clone)] +pub(crate) struct Successor { + sequence: u64, + timestamp: Timestamp, + stamp: u32, + track: kio::ConsumerWeak, +} + +impl Successor { + /// The start this still names, re-read from its own track, or `None` once that + /// group is gone or no longer stamped. Takes that track's lock, so never call it + /// under another's. + fn start(&self) -> Option { + let state = self.track.read(); + let slot = state.lookup.get(&self.sequence)?; + if slot.stamp != self.stamp || slot.group.is_aborted() { + return None; + } + slot.group.timestamp() + } +} + +impl PartialEq for Successor { + fn eq(&self, other: &Self) -> bool { + self.sequence == other.sequence + && self.timestamp == other.timestamp + && self.stamp == other.stamp + && self.track.same_channel(&other.track) + } } /// The newest servable group that has presented at least one frame. @@ -3077,8 +3334,9 @@ struct PlainSubscriber { /// segment), folded into the drift anchor only. Delivery is still bounded by /// `end_sequence`, which stays unset on a segment so its completion is visible. stale_cap: Option, - /// Shared effective cap used by groups after this cursor hands them out. - drift_cap: kio::Producer>, + /// Shared effective anchor used by groups after this cursor hands them out. The + /// only copy of the outer edge a wrapping reader pushed (see [`Anchor::edge`]). + drift_anchor: kio::Producer, /// Groups the drift budget skipped since the count was last drained. Accumulated /// here rather than metered in place because the handle that owns the stats scope /// is the outer [`Subscriber`], which may be reading this cursor through a @@ -3091,9 +3349,20 @@ struct PlainSubscriber { } impl PlainSubscriber { - fn update_drift_cap(&mut self) { - if let Ok(mut cap) = self.drift_cap.write() { - *cap = servable_cap(self.end_sequence, self.stale_cap); + /// The drift anchor for a read bounded by `end`. Every read folds `end_sequence` + /// into `end`, so capping the shared anchor (which already holds it) is exact. + fn anchor(&self, end: Option) -> Anchor { + self.drift_anchor.read().clone().capped(end) + } + + /// Publish the `outer` anchor under this cursor's own cap. + fn update_drift_anchor(&mut self, outer: Anchor) { + let anchor = outer.capped(self.end_sequence); + // Skip a no-op write: every handed-out group's expiry watches this channel. + if *self.drift_anchor.read() != anchor + && let Ok(mut current) = self.drift_anchor.write() + { + *current = anchor; } } @@ -3130,16 +3399,23 @@ impl PlainSubscriber { /// discarding a backlog of N groups costs one scan rather than N. Only ever /// [`Poll::Ready`]; the track ending surfaces as the error the caller was going to /// get anyway. - fn poll_drift(&self, cap: Option, waiter: &kio::Waiter) -> Poll> { + fn poll_drift(&self, anchor: Anchor, waiter: &kio::Waiter) -> Poll> { let mut max_age = Duration::default(); let _ = self.subscription.poll(waiter, |subscription| { max_age = subscription.max_age; Poll::<()>::Pending }); - self.poll(waiter, move |state| { + let cap = anchor.cap; + let outer = anchor.edge; + let successor = anchor.successor; + self.poll(waiter, |state| { + // Local edge only. The pushed edge and successor are revalidated in + // [`Self::poll_stale`], outside this lock. Poll::Ready(Ok(Drift { budget: clamp_max_age(max_age, state.max_age_bound()), - edge: state.live_edge(cap), + edge: state.drift_edge(cap, None, None), + outer: outer.clone(), + successor: successor.clone(), })) }) } @@ -3147,8 +3423,25 @@ impl PlainSubscriber { /// Whether the drift budget says to skip `group`, against a [`Drift`] already resolved /// for this poll. fn poll_stale(&self, group: &group::Consumer, drift: &Drift, waiter: &kio::Waiter) -> Poll> { + // Revalidate before this track's lock. Both can name another track, including + // one whose own judgment is waiting on this one. + let outer = drift + .outer + .as_ref() + .filter(|live| live.is_live()) + .map(|live| (live.sequence, live.timestamp)); + let successor = drift.successor.as_ref().and_then(Successor::start); + let presentation = drift.edge.presentation; + let cap = drift.edge.cap; + let budget = drift.budget; self.poll(waiter, move |state| { - Poll::Ready(Ok(state.is_stale(group.sequence, drift.edge.as_ref(), drift.budget))) + let edge = Edge { + presentation, + outer, + cap, + successor, + }; + Poll::Ready(Ok(state.is_stale(group.sequence, &edge, budget))) }) } @@ -3157,7 +3450,7 @@ impl PlainSubscriber { group.with_expiry(Arc::new(GroupExpiry { state: self.state.weak(), subscription: self.subscription.consume(), - cap: self.drift_cap.consume(), + anchor: self.drift_anchor.consume(), bound: None, sequence, })) @@ -3185,7 +3478,7 @@ impl PlainSubscriber { .retain(|sequence, group| *sequence >= min_sequence && watch(group)); // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(servable_cap(self.end_sequence, self.stale_cap), waiter))?; + let drift = ready!(self.poll_drift(self.anchor(self.end_sequence), waiter))?; loop { // Re-offer the lowest parked group back inside the cap once it rises, @@ -3284,7 +3577,7 @@ impl PlainSubscriber { let mut floor = floor.max(self.min_sequence); let end = super::subscription::min_some(end, self.end_sequence); // One scan for the whole poll, so walking a backlog off stays linear in its size. - let drift = ready!(self.poll_drift(servable_cap(end, self.stale_cap), waiter))?; + let drift = ready!(self.poll_drift(self.anchor(end), waiter))?; loop { let Some(producer) = ready!(self.poll(waiter, |state| state.poll_next_in_range(floor, end))?) else { @@ -3415,21 +3708,22 @@ impl Subscriber { } /// Bound the drift anchor from outside, for a reader that caps this subscriber - /// without capping its cursor. + /// without capping its cursor, and splices it with other tracks. /// /// A [`super::resume::Subscriber`] segment is deliberately left uncapped /// ([`Self::set_groups`] would park boundary-crossing groups where its completion can't /// be seen), so its own cap has to reach the anchor this way or the segment measures - /// drift against groups its reader will never be served. A spliced segment folds the - /// cap into what it pushes onto its own segments, so the bound reaches the plain - /// cursors at the leaves however deep the splices nest. - pub(crate) fn set_stale_cap(&mut self, cap: Option) { + /// drift against groups its reader will never be served. The same goes for the edge: + /// a segment's track never sees the groups of the segments after it. A spliced + /// segment folds the anchor into what it pushes onto its own segments, so it reaches + /// the plain cursors at the leaves however deep the splices nest. + pub(crate) fn set_anchor(&mut self, anchor: Anchor) { match &mut self.inner { SubscriberKind::Plain(plain) => { - plain.stale_cap = cap; - plain.update_drift_cap(); + plain.stale_cap = anchor.cap; + plain.update_drift_anchor(anchor); } - SubscriberKind::Spliced(spliced) => spliced.set_stale_cap(cap), + SubscriberKind::Spliced(spliced) => spliced.set_anchor(anchor), } } @@ -3467,7 +3761,7 @@ impl Subscriber { SubscriberKind::Plain(plain) => plain, SubscriberKind::Spliced(spliced) => return spliced.poll_stale(group, waiter), }; - let drift = ready!(plain.poll_drift(servable_cap(plain.end_sequence, plain.stale_cap), waiter))?; + let drift = ready!(plain.poll_drift(plain.anchor(plain.end_sequence), waiter))?; let stale = ready!(plain.poll_stale(group, &drift, waiter))?; if stale { plain.note_stale(group); @@ -3694,7 +3988,13 @@ impl Subscriber { match &mut self.inner { SubscriberKind::Plain(plain) => { plain.end_sequence = end.exclusive(); - plain.update_drift_cap(); + // A successor dropped by a lower cap stays dropped until the wrapping + // reader pushes its anchor again, which it does on every poll. + let outer = Anchor { + cap: plain.stale_cap, + ..plain.drift_anchor.read().clone() + }; + plain.update_drift_anchor(outer); } SubscriberKind::Spliced(spliced) => spliced.end_at(end), } @@ -3899,6 +4199,10 @@ pub struct Request { // Ingress stats scope, threaded into the accepted [`Producer`]. Empty (no-op) // unless this request was reserved on a tagged broadcast. stats: stats::Scope, + + // The serving session resolves the start of each subscription itself, so the + // accepted track's start is unknown until it says (see [`Self::resolving_start`]). + resolving_start: bool, } impl Request { @@ -3915,9 +4219,19 @@ impl Request { alive, _dynamic: dynamic, stats: stats::Scope::default(), + resolving_start: false, } } + /// Mark the track as served by a session that resolves each subscription's start + /// (lite-06+), so [`Consumer::poll_start`] waits for its declaration instead of + /// reading the requested floor as the start. Applied atomically with + /// [`Self::accept`], before any reader can see the track. + pub(crate) fn resolving_start(mut self) -> Self { + self.resolving_start = true; + self + } + /// Attach an ingress stats scope, applied to the [`Producer`] on accept. Set by /// a tagged [`broadcast::Producer::reserve_track`]. pub(crate) fn with_stats(mut self, scope: stats::Scope) -> Self { @@ -3982,6 +4296,7 @@ impl Request { // tolerate it: the Producer we hand back simply can't write. if let Ok(mut state) = self.state.write() { state.accept(info.clone()); + state.start_pending = self.resolving_start; } // Accepting the request creates the track producer: count it as one ingress // subscription (closed when the last handle drops). No-op when untagged. @@ -5956,10 +6271,12 @@ mod test { let state = producer.state.read(); let drift = Drift { budget: Duration::ZERO, - edge: state.live_edge(None), + edge: state.drift_edge(None, None, None), + outer: None, + successor: None, }; assert!( - state.is_stale(0, drift.edge.as_ref(), drift.budget), + state.is_stale(0, &drift.edge, drift.budget), "stale against a live edge" ); drop(state); @@ -5970,11 +6287,94 @@ mod test { let state = producer.state.read(); assert!( - !state.is_stale(0, drift.edge.as_ref(), drift.budget), + !state.is_stale(0, &drift.edge, drift.budget), "a vanished edge is no reason to drop what is left" ); } + /// The same holds for an edge a splice pushed from another segment: a handed-out + /// group judges it long after the splice resolved it, so it is revalidated on its own + /// track before it convicts anything. + #[tokio::test] + async fn an_evicted_outer_edge_convicts_nothing() { + let mut producer = track_producer("a", None); + let mut subscriber = producer.subscribe(Subscription::default().with_max_age(Duration::from_secs(1))); + let mut open = producer.append_group().unwrap(); + open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"a")) + .unwrap(); + let mut group = subscriber.recv_group().await.unwrap().expect("group"); + assert!(group.read_frame().await.unwrap().is_some()); + // A successor bounds the open group's reach, without being late against it. + append_at(&mut producer, 10); + + let mut next = track_producer("b", None); + append_at(&mut next, 30_000); + let edge = append_at(&mut next, 30_010); + subscriber.set_anchor(Anchor { + cap: None, + edge: next.consume().live_edge(None), + successor: None, + }); + + let mut control = group.clone(); + assert!( + matches!(control.read_frame().now_or_never(), Some(Ok(None))), + "the open group ends against the outer edge" + ); + + // The outer edge dies before the group is judged again. + let slot = next.modify().unwrap().lookup.remove(&edge).unwrap(); + let _ = slot.group.abort(Error::Evicted); + + assert!( + group.read_frame().now_or_never().is_none(), + "a vanished outer edge is no reason to drop what is left" + ); + assert!(!group.latency_expired()); + open.finish().unwrap(); + } + + /// A pushed successor is the same kind of cached fact. Evicting it must not keep + /// bounding the previous segment's last group while a later group still anchors the + /// outer edge. + #[tokio::test] + async fn an_evicted_successor_convicts_nothing() { + let producer = track_producer("a", None); + let mut subscriber = producer.subscribe(Subscription::default().with_max_age(Duration::from_secs(1))); + let mut open = producer.append_group().unwrap(); + open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"a")) + .unwrap(); + let mut group = subscriber.recv_group().await.unwrap().expect("group"); + assert!(group.read_frame().await.unwrap().is_some()); + + let mut next = track_producer("b", None); + // Early enough that group 0 is already past the budget, and not itself the edge. + let successor = append_at(&mut next, 10); + append_at(&mut next, 30_000); + let consumer = next.consume(); + subscriber.set_anchor(Anchor { + cap: None, + edge: consumer.live_edge(None), + successor: consumer.served_start(successor, None), + }); + + let mut control = group.clone(); + assert!( + matches!(control.read_frame().now_or_never(), Some(Ok(None))), + "the open group ends against the successor" + ); + + let slot = next.modify().unwrap().lookup.remove(&successor).unwrap(); + let _ = slot.group.abort(Error::Evicted); + + assert!( + group.read_frame().now_or_never().is_none(), + "a vanished successor is no reason to drop what is left" + ); + assert!(!group.latency_expired()); + open.finish().unwrap(); + } + #[tokio::test] async fn a_lower_sequence_is_never_the_live_edge() { let producer = track_producer("test", None); @@ -6325,6 +6725,50 @@ mod test { assert!(done.is_none(), "consumer should drain then see clean finish"); } + /// A boundary declared ahead of the live edge, then the last producer dropping, means + /// the missing groups will never come: the reader ends cleanly rather than with + /// `Dropped`, as it would for a track that never declared its end. + #[tokio::test] + async fn drop_short_of_the_boundary_ends_cleanly() { + let mut producer = track_producer("test", None); + producer.append_group().unwrap(); + producer.finish_at(3).unwrap(); + + let mut consumer = producer.subscribe(None); + assert_eq!(consumer.assert_group().sequence, 0); + assert!( + consumer.recv_group().now_or_never().is_none(), + "groups 1 and 2 are owed" + ); + + drop(producer); + let done = consumer.recv_group().now_or_never().expect("should not block").unwrap(); + assert!( + done.is_none(), + "the track ends at its boundary without the missing groups" + ); + } + + /// The sequence cursor ends the same way. A gap below the boundary is skipped, + /// a later cached group is still delivered, and the read finishes cleanly. + #[tokio::test] + async fn ordered_ends_cleanly_when_sealed_short_of_the_boundary() { + let mut producer = track_producer("test", None); + producer.create_group(group::Info { sequence: 0 }).unwrap(); + producer.create_group(group::Info { sequence: 2 }).unwrap(); + producer.finish_at(4).unwrap(); + + let mut ordered = producer.subscribe(None).ordered(); + drop(producer); + + let first = ordered.next_group().now_or_never().expect("group 0").unwrap().unwrap(); + assert_eq!(first.sequence, 0); + let second = ordered.next_group().now_or_never().expect("group 2").unwrap().unwrap(); + assert_eq!(second.sequence, 2); + let done = ordered.next_group().now_or_never().expect("end").unwrap(); + assert!(done.is_none(), "missing groups below the boundary are not an error"); + } + #[tokio::test] async fn cached_groups_preserve_arrival_order() { let producer = track_producer("test", None); diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 0d8ffed032..e26f5e632d 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -7,8 +7,8 @@ use crate::origin; use crate::time::{Clock, Instant}; use crate::{ ALPN_14, ALPN_15, ALPN_16, ALPN_17, ALPN_18, ALPN_19, ALPN_20, ALPN_21, ALPN_22, ALPN_LITE, ALPN_LITE_03, - ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07, Consume, Error, NEGOTIATED, Role, Session, SessionError, - Version, Versions, + ALPN_LITE_04, ALPN_LITE_05, ALPN_LITE_06, ALPN_LITE_07_WIP, Consume, Error, NEGOTIATED, Role, Session, + SessionError, Version, Versions, coding::{Decode, Encode, Stream}, ietf, lite, setup, stats, }; @@ -157,9 +157,9 @@ impl Server { { let runtime = Clock::new(now); let (path, role, origin, handshake) = match session.protocol() { - Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07)) => { + Some(alpn @ (ALPN_LITE_05 | ALPN_LITE_06 | ALPN_LITE_07_WIP)) => { let version = match alpn { - ALPN_LITE_07 => lite::Version::Lite07, + ALPN_LITE_07_WIP => lite::Version::Lite07, ALPN_LITE_06 => lite::Version::Lite06, _ => lite::Version::Lite05, }; @@ -294,7 +294,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 | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03) => { + 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; } Some(ALPN_LITE) | None => { diff --git a/rs/moq-net/src/tail.rs b/rs/moq-net/src/tail.rs new file mode 100644 index 0000000000..34e2ff6a29 --- /dev/null +++ b/rs/moq-net/src/tail.rs @@ -0,0 +1,127 @@ +//! A subscription's tail: the group streams still owed once the publisher has ended it. +//! +//! A publisher ends a subscription only after every group stream it opened has finished, +//! but QUIC does not order streams, so one opened before the end can still reach us after +//! it. The subscriber keeps the subscription routable until each owed group is accounted +//! for, or a grace expires for a group whose stream was reset before its header arrived. +//! A reset that keeps the header (reliable reset) would make the grace unnecessary. + +use std::{ops::Range, task::Poll, time::Duration}; + +use crate::runtime::Deadline; + +/// How long a subscriber waits for a group stream it cannot account for once the +/// publisher has ended the subscription. +/// +/// Bounds the wait on IETF, and on moq-lite when the subscription has no max age to bound +/// it with. Matches `@moq/net`, so a reader cannot tell which side it is talking to. +pub(crate) const GRACE: Duration = Duration::from_secs(1); + +/// The data streams a subscription has received, and the groups they account for. +#[derive(Default, Debug)] +pub(crate) struct Tail { + // Disjoint, sorted, non-adjacent ranges of accounted sequences, so this grows with the + // number of gaps rather than the number of groups. + accounted: Vec>, + streams: u64, +} + +impl Tail { + /// Record a group stream whose header arrived. + pub fn open(&mut self, sequence: u64) { + self.stream(); + self.account(sequence..sequence.saturating_add(1)); + } + + /// Record a data stream that names no single group, such as a fill. + pub fn stream(&mut self) { + self.streams += 1; + } + + /// Record groups as accounted for without a stream: dropped, or sent as a datagram. + pub fn account(&mut self, groups: Range) { + if groups.is_empty() { + return; + } + + // Every range the insert overlaps or touches merges with it into one. + let first = self.accounted.partition_point(|range| range.end < groups.start); + let last = self.accounted.partition_point(|range| range.start <= groups.end); + let merged = match &self.accounted[first..last] { + [] => groups, + [head, .., tail] => head.start.min(groups.start)..tail.end.max(groups.end), + [only] => only.start.min(groups.start)..only.end.max(groups.end), + }; + self.accounted.splice(first..last, [merged]); + } + + /// Whether every group in `groups` is accounted for. + pub fn covers(&self, groups: Range) -> bool { + // Ranges merge on insert, so one range covers the span or none does. + groups.is_empty() + || self + .accounted + .iter() + .any(|range| range.start <= groups.start && groups.end <= range.end) + } + + /// Data streams received, whether they finished or were reset. + pub fn streams(&self) -> u64 { + self.streams + } +} + +/// Waits out a subscription's tail: until the owed streams are accounted for, or the grace. +pub(crate) struct Settle { + tail: kio::Consumer, + grace: Deadline, +} + +impl Settle { + /// Start waiting on `tail`, giving up on missing streams after `grace`. + pub fn new(runtime: &crate::time::Clock, tail: kio::Consumer, grace: Duration) -> Self { + Self { + tail, + grace: Deadline::after(runtime, grace), + } + } + + /// Ready once `complete` holds, the grace expires, or the subscription is gone. + pub fn poll(&mut self, waiter: &kio::Waiter, mut complete: impl FnMut(&Tail) -> bool) -> Poll<()> { + if self.grace.poll(waiter).is_ready() { + return Poll::Ready(()); + } + self.tail + .poll(waiter, |tail| match complete(tail) { + true => Poll::Ready(()), + false => Poll::Pending, + }) + .map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranges_merge_across_gaps() { + let mut tail = Tail::default(); + tail.open(0); + tail.open(2); + assert!(!tail.covers(0..3), "group 1 is missing"); + assert_eq!(tail.accounted, vec![0..1, 2..3]); + + tail.account(1..2); + assert!(tail.covers(0..3) && tail.accounted.len() == 1, "adjacent ranges merge"); + + tail.account(5..7); + tail.account(9..10); + tail.account(4..9); + assert_eq!(tail.accounted, vec![0..3, 4..10], "one insert swallows several ranges"); + assert!(tail.covers(4..10)); + assert!(!tail.covers(2..5)); + assert!(tail.covers(7..7), "an empty range is always covered"); + assert_eq!(tail.streams(), 2, "only streams count, not drops"); + } +} diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index a0ab24b216..e84ab91b38 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -16,8 +16,11 @@ pub(crate) const NEGOTIATED: [Version; 3] = [ /// ALPN strings for supported versions, most-preferred first. `ALPNS[0]` is the /// newest moq-lite ALPN that both sides converge on. +/// +/// `ALPN_LITE_07_WIP` is deliberately absent: lite-07's wire format is still +/// work-in-progress, so it is never advertised or negotiated by default. It is only +/// reachable when both peers explicitly opt in (e.g. `--version moq-lite-07-wip`). pub const ALPNS: &[&str] = &[ - ALPN_LITE_07, ALPN_LITE_06, ALPN_LITE_05, ALPN_LITE_04, @@ -40,7 +43,7 @@ pub(crate) const ALPN_LITE_03: &str = "moq-lite-03"; pub(crate) const ALPN_LITE_04: &str = "moq-lite-04"; pub(crate) const ALPN_LITE_05: &str = "moq-lite-05"; pub(crate) const ALPN_LITE_06: &str = "moq-lite-06"; -pub(crate) const ALPN_LITE_07: &str = "moq-lite-07"; +pub(crate) const ALPN_LITE_07_WIP: &str = "moq-lite-07-wip"; pub(crate) const ALPN_14: &str = "moq-00"; pub(crate) const ALPN_15: &str = "moqt-15"; pub(crate) const ALPN_16: &str = "moqt-16"; @@ -95,7 +98,7 @@ impl Version { Self::Lite(lite::Version::Lite04) => "moq-lite-04", Self::Lite(lite::Version::Lite05) => "moq-lite-05", Self::Lite(lite::Version::Lite06) => "moq-lite-06", - Self::Lite(lite::Version::Lite07) => "moq-lite-07", + Self::Lite(lite::Version::Lite07) => "moq-lite-07-wip", Self::Ietf(ietf::Version::Draft14) => "moq-transport-14", Self::Ietf(ietf::Version::Draft15) => "moq-transport-15", Self::Ietf(ietf::Version::Draft16) => "moq-transport-16", @@ -164,7 +167,7 @@ impl Version { ALPN_LITE_04 => Some(Self::Lite(lite::Version::Lite04)), ALPN_LITE_05 => Some(Self::Lite(lite::Version::Lite05)), ALPN_LITE_06 => Some(Self::Lite(lite::Version::Lite06)), - ALPN_LITE_07 => Some(Self::Lite(lite::Version::Lite07)), + ALPN_LITE_07_WIP => Some(Self::Lite(lite::Version::Lite07)), ALPN_14 => Some(Self::Ietf(ietf::Version::Draft14)), ALPN_15 => Some(Self::Ietf(ietf::Version::Draft15)), ALPN_16 => Some(Self::Ietf(ietf::Version::Draft16)), @@ -181,7 +184,7 @@ impl Version { /// Returns the ALPN string for this version. pub fn alpn(&self) -> &'static str { match self { - Self::Lite(lite::Version::Lite07) => ALPN_LITE_07, + Self::Lite(lite::Version::Lite07) => ALPN_LITE_07_WIP, Self::Lite(lite::Version::Lite06) => ALPN_LITE_06, Self::Lite(lite::Version::Lite05) => ALPN_LITE_05, Self::Lite(lite::Version::Lite04) => ALPN_LITE_04, @@ -294,8 +297,17 @@ pub struct Versions(Vec); impl Versions { /// All versions exposed by default. + /// + /// `Lite07` is intentionally excluded: its wire format is still work-in-progress, + /// so it is not advertised until a caller opts in explicitly (e.g. a pinned + /// `version = ["moq-lite-07-wip"]`). An opt-in set that includes it negotiates normally. pub fn all() -> Self { - Self(ALL.to_vec()) + Self( + ALL.iter() + .filter(|version| !matches!(version, Version::Lite(lite::Version::Lite07))) + .copied() + .collect(), + ) } /// Compute the unique ALPN strings needed for these versions. @@ -372,13 +384,28 @@ mod tests { use super::*; #[test] - fn default_versions_prefer_lite_07() { - let newest = Version::Lite(lite::Version::Lite07); - assert_eq!(newest.alpn(), "moq-lite-07"); + fn default_versions_prefer_lite_06() { + let newest = Version::Lite(lite::Version::Lite06); + assert_eq!(newest.alpn(), "moq-lite-06"); assert_eq!(Version::from_alpn("moq-lite-06-wip"), None); assert!("moq-lite-06-wip".parse::().is_err()); assert_eq!(Versions::all().iter().next(), Some(&newest)); assert_eq!(Versions::all().alpns().first(), Some(&newest.alpn())); assert_eq!(ALPNS.first(), Some(&newest.alpn())); } + + #[test] + fn lite_07_wip_is_opt_in_only() { + let wip = Version::Lite(lite::Version::Lite07); + assert_eq!(wip.alpn(), "moq-lite-07-wip"); + assert_eq!(wip.to_string(), "moq-lite-07-wip"); + assert_eq!("moq-lite-07-wip".parse::(), Ok(wip)); + assert_eq!(Version::from_alpn("moq-lite-07-wip"), Some(wip)); + assert!("moq-lite-07".parse::().is_err()); + assert_eq!(Version::from_alpn("moq-lite-07"), None); + + assert!(!Versions::all().contains(&wip)); + assert!(!ALPNS.contains(&wip.alpn())); + assert_eq!(Versions::from(wip).alpns(), [wip.alpn()]); + } } diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index 2eefac0f0a..c85713fa82 100644 --- a/rs/moq-net/tests/goaway.rs +++ b/rs/moq-net/tests/goaway.rs @@ -338,7 +338,7 @@ async fn goaway_gates_new_subscribes_moq_lite_04() { let mut opts = MockConnectOptions::new(version); opts.server_publish = Some(pub_origin.clone()); opts.client_subscribe = Some(sub_origin.clone()); - let MockPair { client, server } = connect_mock(opts).await; + let MockPair { client, server, .. } = connect_mock(opts).await; // Subscribe BEFORE the GOAWAY and receive a first group. let sub = sub_origin.consume(); @@ -425,7 +425,7 @@ async fn goaway_drains_routes(version: Version) { let mut opts = MockConnectOptions::new(version); opts.server_publish = Some(pub_origin.clone()); opts.client_subscribe = Some(sub_origin.clone()); - let MockPair { client, server } = connect_mock(opts).await; + let MockPair { client, server, .. } = connect_mock(opts).await; let sub = sub_origin.consume(); let route = sub.routed("test").await.expect("route announced"); diff --git a/rs/moq-net/tests/support/harness.rs b/rs/moq-net/tests/support/harness.rs index 3eba29225a..701d83fb32 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -9,7 +9,7 @@ use moq_net::{Client, Server, Session, Version, origin}; -use super::mock::create_mock_session_pair; +use super::mock::{MockSession, create_mock_session_pair}; pub use moq_net::time::run; @@ -49,6 +49,10 @@ impl MockConnectOptions { pub struct MockPair { pub client: Session, pub server: Session, + /// The client's end of the mock transport, for steering delivery. + pub client_transport: MockSession, + /// The server's end of the mock transport, for steering delivery. + pub server_transport: MockSession, } /// Run the MoQ handshake over the mock transport, returning connected sessions. @@ -63,6 +67,7 @@ pub struct MockPair { pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { let protocol = opts.version.alpn(); let (client_transport, server_transport) = create_mock_session_pair(Some(protocol)); + let transports = (client_transport.clone(), server_transport.clone()); let mut client = Client::new().with_versions(opts.version.into()); if let Some(publish) = &opts.client_publish { @@ -105,5 +110,7 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { MockPair { client: client_session, server: server_session, + client_transport: transports.0, + server_transport: transports.1, } } diff --git a/rs/moq-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index 35caf23c7a..95d4b50b7c 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -11,7 +11,7 @@ //! plagues real-transport tests. use std::{ - sync::Arc, + sync::{Arc, Mutex}, task::{Context, Poll}, }; @@ -103,6 +103,9 @@ pub struct MockSendStream { tx: Option>, closed: Arc, park: kio::Park, + /// Acknowledge the FIN as soon as it is sent, for a stream the peer's transport holds + /// back from its application (see [`MockSession::hold_unis`]). + ack_fin: bool, } impl poll::SendStream for MockSendStream { @@ -123,6 +126,9 @@ impl poll::SendStream for MockSendStream { fn finish(&mut self) -> Result<(), Self::Error> { if let Some(tx) = self.tx.take() { let _ = tx.try_push(StreamChunk::Fin); + if self.ack_fin { + self.closed.set(Ok(())); + } } Ok(()) } @@ -257,6 +263,7 @@ fn new_stream_pair() -> (MockSendStream, MockRecvStream) { tx: Some(queue.clone()), closed: closed.clone(), park: kio::Park::default(), + ack_fin: false, }; let recv = MockRecvStream { rx: queue, @@ -299,6 +306,8 @@ struct SessionSide { protocol: Option<&'static str>, /// Connection-level close state shared with the peer. conn: Arc, + /// Uni streams this side opened that the peer has not accepted yet, while held. + held: Mutex>>, } /// An in-memory mock WebTransport session. @@ -363,7 +372,13 @@ impl poll::Session for MockSession { } fn poll_open_uni(&mut self, _cx: &mut Context<'_>) -> Poll> { - let (our_send, peer_recv) = new_stream_pair(); + let (mut our_send, peer_recv) = new_stream_pair(); + + if let Some(held) = self.side.held.lock().unwrap().as_mut() { + our_send.ack_fin = true; + held.push(peer_recv); + return Poll::Ready(Ok(our_send)); + } // Deliver peer_recv to the peer's accept_uni. match self.side.peer_uni.try_push(peer_recv) { @@ -420,6 +435,34 @@ impl poll::Session for MockSession { } } +// Only some test binaries steer delivery. +#[allow(dead_code)] +impl MockSession { + /// Hold back the uni streams this side opens from now on. + /// + /// The peer's transport has them, so a FIN is acknowledged at once, but its application + /// does not see them until [`Self::release_unis`]. That is QUIC delivering streams out of + /// order: a publisher can see a group stream acknowledged and end the subscription + /// before the subscriber has read the group's header. + pub fn hold_unis(&self) { + self.side.held.lock().unwrap().get_or_insert_default(); + } + + /// Deliver the held uni streams to the peer in the order they were opened, and stop + /// holding. + pub fn release_unis(&self) { + for stream in self.side.held.lock().unwrap().take().unwrap_or_default() { + 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) { + self.side.held.lock().unwrap().take(); + } +} + impl MockSession { fn close_error(&self) -> MockError { self.side @@ -463,6 +506,7 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, peer_datagrams: c2s_datagrams.clone(), protocol, conn: conn.clone(), + held: Mutex::default(), }); let server_side = Arc::new(SessionSide { @@ -474,6 +518,7 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, peer_datagrams: s2c_datagrams, protocol, conn, + held: Mutex::default(), }); let new = |side| MockSession { diff --git a/rs/moq-net/tests/track_tail.rs b/rs/moq-net/tests/track_tail.rs new file mode 100644 index 0000000000..28ef3d957f --- /dev/null +++ b/rs/moq-net/tests/track_tail.rs @@ -0,0 +1,192 @@ +//! A track's tail: a group stream that reaches the subscriber after the publisher has +//! ended the subscription still belongs to the track. +//! +//! Publishers end a subscription only once every group stream they opened is finished, +//! but QUIC does not order streams, so the subscriber can read the end (moq-lite's +//! subscribe stream FIN, IETF's PUBLISH_DONE) before a group's header. The mock holds the +//! publisher's group streams back from the subscriber to make that ordering +//! deterministic, while acknowledging them to the publisher like a real transport would. +//! +//! Time is paused, so the one-second grace for a group that never arrives is free. + +mod support; + +use std::time::Duration; + +use moq_net::{Hop, Timestamp, Version}; +use support::harness::{MockConnectOptions, connect_mock}; + +const TIMEOUT: Duration = Duration::from_secs(10); +const PAYLOAD: &[u8] = b"frame"; + +/// How long a subscriber waits for a group it cannot account for, with no max age set. +const GRACE: Duration = Duration::from_secs(1); + +/// moq-lite drafts with and without SUBSCRIBE_END, and IETF drafts over the control stream +/// adapter (14), on their own streams (17), and with subscription fills (20+). +const VERSIONS: &[&str] = &[ + "moq-lite-03", + "moq-lite-05", + "moq-lite-07-wip", + "moq-transport-14", + "moq-transport-17", + "moq-transport-20", + "moq-transport-22", +]; + +/// What becomes of the group streams held back past the subscription's end. +#[derive(Clone, Copy, Debug)] +enum Late { + /// They arrive after the end. + Delivered, + /// They never arrive, like a stream reset before its header. + Lost, +} + +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 +} + +struct Outcome { + frames: Vec>, + err: Option, + /// How long after the held streams were released (or lost) the track ended. + 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 { + let publisher = produce_origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let subscriber = produce_origin(2); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_publish = Some(publisher.clone()); + 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 = moq_net::track::Subscription::default().with_start(moq_net::track::Position::group(0)); + let mut sub = remote + .track("video") + .unwrap() + .subscribe(subscription) + .await + .expect("subscribe"); + let mut frames = Vec::new(); + let err = loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => break None, + Err(err) => break Some(err), + }; + loop { + match group.read_frame().await { + Ok(Some(frame)) => frames.push(frame.payload.to_vec()), + Ok(None) => break, + Err(err) => panic!("group failed: {err}"), + } + } + }; + (frames, err, tokio::time::Instant::now()) + }); + + tokio::time::timeout(TIMEOUT, track.used()) + .await + .expect("no subscriber appeared") + .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(); + 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" + ); + + let released = tokio::time::Instant::now(); + match late { + Late::Delivered => pair.server_transport.release_unis(), + Late::Lost => pair.server_transport.drop_unis(), + } + + let (frames, err, ended) = tokio::time::timeout(TIMEOUT, reader) + .await + .expect("the subscription never ended") + .expect("reader panicked"); + drop((pair, broadcast, publisher, subscriber)); + Outcome { + frames, + err, + elapsed: ended - released, + } +} + +/// A group whose header arrives after the subscription's end is delivered, then the track +/// ends cleanly. +#[tokio::test] +async fn a_group_after_the_end_is_delivered() { + tokio::time::pause(); + for version in VERSIONS { + let outcome = round(version, Late::Delivered).await; + assert!( + outcome.err.is_none() && outcome.frames == [PAYLOAD], + "{version}: got {} frame(s), err={:?}", + outcome.frames.len(), + outcome.err, + ); + + // Drafts that say where the track ends, or how many streams the publisher opened, + // end as soon as the last group arrives rather than waiting out the grace. + if *version != "moq-lite-03" { + assert!( + outcome.elapsed < GRACE / 10, + "{version}: ended after {:?}", + outcome.elapsed + ); + } + } +} + +/// A group that never arrives is given up on after the grace, and the track still ends +/// cleanly without it. +#[tokio::test] +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; + assert!( + outcome.err.is_none() && outcome.frames.is_empty(), + "{version}: got {} frame(s), err={:?}", + outcome.frames.len(), + outcome.err, + ); + assert!( + outcome.elapsed >= GRACE / 2, + "{version}: ended after {:?}", + outcome.elapsed + ); + } +} diff --git a/rs/moq-nvenc/CHANGELOG.md b/rs/moq-nvenc/CHANGELOG.md index ebe6d7e750..3c433c654f 100644 --- a/rs/moq-nvenc/CHANGELOG.md +++ b/rs/moq-nvenc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/moq-dev/moq/compare/moq-nvenc-v0.1.0...moq-nvenc-v0.1.1) - 2026-09-25 + +### Fixed + +- *(nvenc)* release the session after a failed submission ([#4139](https://github.com/moq-dev/moq/pull/4139)) + ## [0.0.6](https://github.com/moq-dev/moq/compare/moq-nvenc-v0.0.5...moq-nvenc-v0.0.6) - 2026-09-23 ### Added diff --git a/rs/moq-nvenc/Cargo.toml b/rs/moq-nvenc/Cargo.toml index 33ad146d72..4674d790c1 100644 --- a/rs/moq-nvenc/Cargo.toml +++ b/rs/moq-nvenc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moq-nvenc" -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT" rust-version.workspace = true diff --git a/rs/moq-nvenc/src/safe/buffer.rs b/rs/moq-nvenc/src/safe/buffer.rs index d184969c3d..3234005885 100644 --- a/rs/moq-nvenc/src/safe/buffer.rs +++ b/rs/moq-nvenc/src/safe/buffer.rs @@ -1,6 +1,6 @@ //! Defines traits and types for dealing with input and output buffers. -use std::{ffi::c_void, ptr, sync::Arc}; +use std::{ffi::c_void, mem::ManuallyDrop, ptr, sync::Arc}; use cudarc::driver::{DevicePtr, MappedBuffer}; @@ -12,8 +12,12 @@ use crate::sys::nvEncodeAPI::{ NV_ENC_MAP_INPUT_RESOURCE_VER, NV_ENC_PIC_TYPE, NV_ENC_REGISTER_RESOURCE, }; -mod sealed { - pub trait Input {} +pub(crate) mod sealed { + pub trait Input { + /// Give up an input the driver may still be reading: leak its handle, but + /// release its encoder reference so the session can still be destroyed. + fn abandon(self); + } } /// An input buffer created or registered by this crate. @@ -413,7 +417,14 @@ impl Drop for Buffer { } } -impl sealed::Input for Buffer {} +impl sealed::Input for Buffer { + fn abandon(self) { + // Skip `Drop`: destroying the buffer could free memory the driver still reads. + let this = ManuallyDrop::new(self); + // SAFETY: read once from a value whose destructor never runs. + drop(unsafe { ptr::read(&this.encoder) }); + } +} impl EncoderInput for Buffer { fn pitch(&self) -> u32 { @@ -580,6 +591,14 @@ impl Bitstream { picture_type: lock_bitstream_buffer_params.pictureType, }) } + + /// Give up a bitstream the driver may still write: leak its handle, but + /// release its encoder reference so the session can still be destroyed. + pub(crate) fn abandon(self) { + let this = ManuallyDrop::new(self); + // SAFETY: read once from a value whose destructor never runs. + drop(unsafe { ptr::read(&this.encoder) }); + } } impl Drop for Bitstream { @@ -707,7 +726,14 @@ impl Drop for Mapping { } } -impl sealed::Input for RegisteredResource {} +impl sealed::Input for RegisteredResource { + fn abandon(self) { + // Skip `Drop`, leaking the registration and the owner of its memory. + let this = ManuallyDrop::new(self); + // SAFETY: read once from a value whose destructor never runs. + drop(unsafe { ptr::read(&this.mapping.api) }); + } +} impl EncoderInput for RegisteredResource { fn pitch(&self) -> u32 { diff --git a/rs/moq-nvenc/src/safe/session.rs b/rs/moq-nvenc/src/safe/session.rs index 6ab1cb0672..5e6156cd51 100644 --- a/rs/moq-nvenc/src/safe/session.rs +++ b/rs/moq-nvenc/src/safe/session.rs @@ -164,10 +164,10 @@ impl Session { /// An encoder-busy result is returned as an error so the caller can retry. /// A need-more-input result is instead represented by the returned /// [`Submission`], which retains both buffers until completion. The facade - /// does not reorder B-frames, so configure the session without them - /// (`frameIntervalP = 1`, as `moq-video` does); completing such a - /// submission waits without sending end-of-stream, leaving the session - /// usable for further frames. + /// does not drive frames the driver holds back, so configure the session + /// without B-frames or lookahead (`frameIntervalP = 1` and low-latency + /// tuning, as `moq-video` does): the driver refuses to lock a held frame's + /// output, and [`Submission::finish`] fails. /// /// Safe code cannot release the input while it is in flight because the /// submission owns it: @@ -331,17 +331,28 @@ pub struct EncodePictureParams { #[derive(Debug)] #[must_use = "dropping a submission waits for completion before releasing its resources"] pub struct Submission { - pending: Pending, + pending: Pending>, } impl Submission { - fn new(input: I, output: Bitstream) -> Self { + fn new(input: I, output: Bitstream) -> Self + where + I: EncoderInput, + { + let driver = SdkDriver { + abandon_input: I::abandon, + }; Self { - pending: Pending::new(SdkDriver, input, output), + pending: Pending::new(driver, input, output), } } /// Wait for completion, copy the encoded bytes, and return reusable buffers. + /// + /// # Errors + /// + /// Returns the driver's error when the output cannot be locked. Both + /// buffers are then abandoned, since the driver may still be using them. pub fn finish(mut self) -> Result<(Vec, I, Bitstream), EncodeError> { self.pending.finish() } @@ -352,62 +363,73 @@ fn same_session(input: &Arc, output: &Arc, session: &Arc) -> bool { } trait CompletionDriver { + type Input; type Output; fn wait(&self, output: &mut Self::Output) -> Result, EncodeError>; + + /// Give up both buffers after a failed wait. + fn abandon(&self, input: Self::Input, output: Self::Output); } #[derive(Debug)] -struct SdkDriver; +struct SdkDriver { + // A function rather than an `EncoderInput` bound, which `Submission` would + // have to repeat in its public signature. + abandon_input: fn(I), +} -impl CompletionDriver for SdkDriver { +impl CompletionDriver for SdkDriver { + type Input = I; type Output = Bitstream; fn wait(&self, output: &mut Self::Output) -> Result, EncodeError> { Ok(output.lock()?.data().to_vec()) } + + fn abandon(&self, input: I, output: Bitstream) { + (self.abandon_input)(input); + output.abandon(); + } } #[derive(Debug)] -struct Pending { +struct Pending { driver: D, - input: Option, - output: Option, + /// `None` once finished or abandoned. + buffers: Option<(D::Input, D::Output)>, } -impl Pending { - fn new(driver: D, input: I, output: D::Output) -> Self { +impl Pending { + fn new(driver: D, input: D::Input, output: D::Output) -> Self { Self { driver, - input: Some(input), - output: Some(output), + buffers: Some((input, output)), } } - fn finish(&mut self) -> Result<(Vec, I, D::Output), EncodeError> { + fn finish(&mut self) -> Result<(Vec, D::Input, D::Output), EncodeError> { + let (input, mut output) = self.buffers.take().expect("submission buffers"); // Wait without sending end-of-stream: flushing here would end the // session, while the caller may still submit further frames. - let data = self.driver.wait(self.output.as_mut().expect("submission output"))?; - let input = self.input.take().expect("submission input"); - let output = self.output.take().expect("submission output"); - Ok((data, input, output)) + match self.driver.wait(&mut output) { + Ok(data) => Ok((data, input, output)), + Err(error) => { + // A failed wait cannot prove the driver released either buffer, so + // leak them rather than permit a use-after-free. Their encoder + // reference still goes: a session left open at exit deadlocks the + // driver's own exit handler, hanging the process. + self.driver.abandon(input, output); + Err(error) + } + } } } -impl Drop for Pending { +impl Drop for Pending { fn drop(&mut self) { - if self.input.is_none() { - return; - } - let completed = self - .driver - .wait(self.output.as_mut().expect("submission output")) - .is_ok(); - if !completed { - // A failed wait cannot prove the driver released either handle. Leak - // them and their encoder rather than permit a use-after-free. - std::mem::forget(self.input.take()); - std::mem::forget(self.output.take()); + if self.buffers.is_some() { + let _ = self.finish(); } } } @@ -416,10 +438,21 @@ impl Drop for Pending { mod tests { use std::sync::{Arc, Mutex}; + use cudarc::driver::CudaContext; + use super::*; + use crate::{ + sys::nvEncodeAPI::{ + NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12, NV_ENC_CODEC_H264_GUID, NV_ENC_PRESET_P7_GUID, + NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_HIGH_QUALITY, + }, + EncoderInitParams, + }; + + type Events = Arc>>; #[derive(Debug)] - struct Resource(&'static str, Arc>>); + struct Resource(&'static str, Events); impl Drop for Resource { fn drop(&mut self) { @@ -428,26 +461,45 @@ mod tests { } #[derive(Debug)] - struct FakeDriver(Arc>>); + struct FakeDriver { + events: Events, + fail: bool, + } impl CompletionDriver for FakeDriver { + type Input = Resource; type Output = Resource; fn wait(&self, _: &mut Self::Output) -> Result, EncodeError> { - self.0.lock().unwrap().push("wait"); + self.events.lock().unwrap().push("wait"); + if self.fail { + return Err(EncodeError::new(ErrorKind::InvalidParam, None)); + } Ok(vec![1, 2, 3]) } + + fn abandon(&self, input: Resource, output: Resource) { + self.events.lock().unwrap().push("abandon"); + std::mem::forget((input, output)); + } } - #[test] - fn delayed_completion_retains_resources_until_wait() { - let events = Arc::new(Mutex::new(Vec::new())); - let mut pending = Pending::new( - FakeDriver(events.clone()), + fn pending(events: &Events, fail: bool) -> Pending { + let driver = FakeDriver { + events: events.clone(), + fail, + }; + Pending::new( + driver, Resource("input", events.clone()), Resource("output", events.clone()), - ); - let (data, input, output) = pending.finish().unwrap(); + ) + } + + #[test] + fn delayed_completion_retains_resources_until_wait() { + let events = Events::default(); + let (data, input, output) = pending(&events, false).finish().unwrap(); assert_eq!(data, [1, 2, 3]); assert_eq!(*events.lock().unwrap(), ["wait"]); drop((input, output)); @@ -456,15 +508,22 @@ mod tests { #[test] fn cancellation_completes_before_releasing_resources() { - let events = Arc::new(Mutex::new(Vec::new())); - drop(Pending::new( - FakeDriver(events.clone()), - Resource("input", events.clone()), - Resource("output", events.clone()), - )); + let events = Events::default(); + drop(pending(&events, false)); assert_eq!(*events.lock().unwrap(), ["wait", "input", "output"]); } + #[test] + fn failed_wait_abandons_without_waiting_again() { + let events = Events::default(); + assert!(pending(&events, true).finish().is_err()); + assert_eq!(*events.lock().unwrap(), ["wait", "abandon"]); + + let events = Events::default(); + drop(pending(&events, true)); + assert_eq!(*events.lock().unwrap(), ["wait", "abandon"]); + } + #[test] fn session_identity_rejects_cross_session_resources() { let first = Arc::new(()); @@ -472,4 +531,70 @@ mod tests { assert!(same_session(&first, &first, &first)); assert!(!same_session(&first, &second, &first)); } + + /// Whether an NVENC session can run here. Hardware tests return early + /// without one, so they pass on GPU-less CI. + fn driver_available() -> bool { + // cudarc panics while loading a missing libcuda, so probe for it first. + // SAFETY: the library is opened only to test presence, never called. + let cuda = ["libcuda.so.1", "libcuda.so"] + .iter() + .any(|name| unsafe { libloading::Library::new(*name) }.is_ok()); + cuda && Encoder::load().is_ok() + } + + /// Lookahead holds frames back, and the driver refuses to lock a held + /// frame's output. P7 with high-quality tuning turns lookahead on. The failed + /// submissions must not keep the session open: one still open at exit + /// deadlocks the driver's exit handler, so the process never exits. + #[test] + fn failed_submission_releases_the_session() { + if !driver_available() { + return; + } + // The libraries can load when no device is assigned. That is the same + // as a missing driver: only a session that can start proves the fix. + let Ok(cuda) = CudaContext::new(0) else { + return; + }; + let Ok(encoder) = Encoder::initialize_with_cuda(cuda) else { + return; + }; + let (codec, preset, tuning) = ( + NV_ENC_CODEC_H264_GUID, + NV_ENC_PRESET_P7_GUID, + NV_ENC_TUNING_INFO_HIGH_QUALITY, + ); + let mut config = encoder.get_preset_config(codec, preset, tuning).unwrap().presetCfg; + assert_eq!( + config.rcParams.enableLookahead(), + 1, + "the preset no longer holds frames" + ); + // No B-frames, so lookahead alone holds the frames. + config.frameIntervalP = 1; + + let mut init = EncoderInitParams::new(codec, 320, 240); + init.preset_guid(preset) + .tuning_info(tuning) + .enable_picture_type_decision(); + // SAFETY: the preset config holds no borrowed extension pointers. + unsafe { init.encode_config(config) }; + let session = encoder.start_session(NV_ENC_BUFFER_FORMAT_NV12, init).unwrap(); + let encoder = Arc::downgrade(&session.encoder); + + let submit = || { + let input = session.create_input_buffer().unwrap(); + let output = session.create_output_bitstream().unwrap(); + session + .encode_picture(input, output, EncodePictureParams::default()) + .unwrap() + }; + // One submission fails to finish and one is dropped unfinished. + assert!(submit().finish().is_err(), "the driver locked a held frame's output"); + drop(submit()); + + drop(session); + assert!(encoder.upgrade().is_none(), "failed submissions kept the session open"); + } } diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 9f3d36d2c0..77d7452de9 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.5](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.4...moq-relay-v0.15.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-tokio, moq-auth, moq-uring, moq-stats + +## [0.15.4](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.3...moq-relay-v0.15.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-tokio, moq-uring, moq-stats + ## [0.15.3](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.2...moq-relay-v0.15.3) - 2026-09-25 ### Added diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 02446cf4b6..8ea12937ab 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.3" +version = "0.15.5" 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 diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 47fe4ba4cd..1574f3e892 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1186,7 +1186,7 @@ impl Cluster { }; // An authenticated cluster peer (a verified client certificate or the LAN // credential) discovers hidden routes whether or not it asks. A peer that - // predates the hidden opt-in (below moq-lite-07, or moq-transport without + // predates the hidden opt-in (below moq-lite-07-wip, or moq-transport without // MoQ Hidden) would otherwise lose `.internal/origins` and every other dot // path during a rolling upgrade. // TODO: drop the exemption once deployed peers all opt in. diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index c8d11df860..8ae772428b 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -241,7 +241,7 @@ where /// /// We advertise the configured qmux × moq-net subprotocol matrix, with bare /// qmux fallbacks last. axum picks the first entry that the client also offered, so -/// a modern client lands on `qmux-01.moq-lite-07`; old clients still match +/// a modern client lands on `qmux-01.moq-lite-06`; old clients still match /// `webtransport` or `qmux-00.moql` and negotiate via SETUP. /// /// When the client offered subprotocols and none of them are ours, the diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 9475aa040d..63b47a00c4 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -262,7 +262,10 @@ async fn announced_until(announcements: &mut moq_net::announce::Consumer, until: /// by exact path needs no opt-in. #[tokio::test] async fn hidden_broadcasts_need_a_lite07_opt_in() { - let (port, web_handle) = spawn_relay().await; + // lite-07 is work-in-progress and off by default, so the relay must enable it. + let lite07: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + let lite06: moq_net::Version = "moq-lite-06".parse().unwrap(); + let (port, web_handle) = spawn_versioned_relay(vec![lite07, lite06]).await; let url: url::Url = format!("ws://127.0.0.1:{port}/hidden").parse().expect("parse url"); let pub_origin = moq_tokio::origin::spawn(); @@ -274,14 +277,17 @@ async fn hidden_broadcasts_need_a_lite07_opt_in() { .expect("append group") .write_frame(moq_net::Timestamp::ZERO, b"hidden".as_ref()) .expect("write frame"); - let (_pub_client, pub_connection) = - tokio::time::timeout(TIMEOUT, connect_once(client().with_publisher(&pub_origin), url.clone())) - .await - .expect("publisher connect timeout") - .expect("publisher connect failed"); + // The relay's announce request carries the opt-in only on lite-07, so the publisher + // must speak it too for the hidden route to reach the relay. + let (_pub_client, pub_connection) = tokio::time::timeout( + TIMEOUT, + connect_once(client_version(Some(lite07)).with_publisher(&pub_origin), url.clone()), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); // An opted-in lite-07 client discovers the hidden broadcast. - let lite07: moq_net::Version = "moq-lite-07".parse().unwrap(); let opted_origin = moq_tokio::origin::spawn(); let mut opted = opted_origin.consume().with_hidden(true).announced(); let (_opted_client, opted_connection) = tokio::time::timeout( @@ -301,7 +307,6 @@ async fn hidden_broadcasts_need_a_lite07_opt_in() { // A lite-07 client that did not opt in, and a lite-06 client that cannot even // when its local reader asks, see only the visible broadcast. - let lite06: moq_net::Version = "moq-lite-06".parse().unwrap(); for (version, local_hidden) in [(lite07, false), (lite06, true)] { let origin = moq_tokio::origin::spawn(); let consumer = origin.consume().with_hidden(local_hidden); diff --git a/rs/moq-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md index d3fdfcb6f0..3f3b26d5f6 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.5](https://github.com/moq-dev/moq/compare/moq-room-v0.2.4...moq-room-v0.2.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-auth, moq-json + +## [0.2.4](https://github.com/moq-dev/moq/compare/moq-room-v0.2.3...moq-room-v0.2.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.3](https://github.com/moq-dev/moq/compare/moq-room-v0.2.2...moq-room-v0.2.3) - 2026-09-25 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index f4c1777a28..cca9dff114 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.3" +version = "0.2.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index db905dbedd..5fb7b1d958 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.5](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.4...moq-rtc-v0.3.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux, hang + +## [0.3.4](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.3...moq-rtc-v0.3.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.3](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.2...moq-rtc-v0.3.3) - 2026-09-25 ### Added diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index 001c43a41b..d0ede4ab36 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.3" +version = "0.3.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index 5550af5e3e..97da0d525e 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.5](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.4...moq-rtmp-v0.3.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux, hang + +## [0.3.4](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.3...moq-rtmp-v0.3.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.3](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.2...moq-rtmp-v0.3.3) - 2026-09-25 ### Added diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index ffaf2634a0..9d538c0c00 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.3" +version = "0.3.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index 9a4e10ebb9..035ee8269b 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.5](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.4...moq-srt-v0.3.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux + +## [0.3.4](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.3...moq-srt-v0.3.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux + ## [0.3.3](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.2...moq-srt-v0.3.3) - 2026-09-25 ### Other diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index 20cb6da8d5..7c881c0673 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.3" +version = "0.3.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index ebc2ff3024..da4c8aa3da 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.5](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.4...moq-stats-v0.2.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json + +## [0.2.4](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.3...moq-stats-v0.2.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.3](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.2...moq-stats-v0.2.3) - 2026-09-25 ### Added diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 295ca18336..8647abedf2 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.3" +version = "0.2.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/src/lib.rs b/rs/moq-stats/src/lib.rs index d1613d7217..99b2411232 100644 --- a/rs/moq-stats/src/lib.rs +++ b/rs/moq-stats/src/lib.rs @@ -15,41 +15,14 @@ //! # Wire format //! //! A [`Producer`] publishes one broadcast per node at `/node/` -//! (default prefix `.stats`; the node suffix disambiguates relays sharing a -//! cluster origin and may be multi-segment, e.g. `sjc/1`). A grouping `depth` -//! splits that into one broadcast per leading broadcast-path segments at -//! `//node/`, so a consumer can announce-scope to a -//! single group. Parse announce paths back with [`parse_node_path`]. -//! -//! Traffic is bucketed by [`Tier`] (an arbitrary label chosen by business -//! logic: billing class, region, ...). The default tier is unprefixed; a named -//! tier prefixes its track names with its label. Each broadcast carries, per -//! tier, a publisher (egress) and a subscriber (ingress) traffic track plus a -//! sessions track, each in a plain and a compressed flavor: -//! -//! * `publisher.json` / `subscriber.json`: each frame is a JSON object mapping -//! broadcast path to a cumulative [`Traffic`] snapshot ([`TrafficFrame`]), -//! one full snapshot per frame. -//! * `sessions.json`: each frame maps auth root to a cumulative [`Presence`] -//! gauge ([`SessionsFrame`]), counting connected sessions regardless of data -//! flow. -//! * `.json.z`: a compressed sibling of each of the above, encoded with -//! [`moq_json::snapshot`] (group-scoped DEFLATE plus RFC 7396 merge-patch -//! deltas). Since successive stats frames are nearly identical, this is a -//! fraction of the plain track's bytes; read it with [`Consumer`] (or -//! `moq_json` directly), not as raw JSON frames. -//! -//! Named-tier tracks (`/publisher.json`, ...) are created the first time -//! traffic records under that label; default-tier tracks always exist and hold -//! `{}` while idle. Compute names with [`traffic_track`] / [`sessions_track`]. -//! -//! An entry appears in a frame while it is live (a started counter still exceeds -//! its `*_ended` counterpart, so traffic could resume at any moment) or on -//! the tick its snapshot changed, then is dropped once fully closed. Counters -//! are cumulative and monotonic: a downstream aggregator computes rates from -//! successive snapshots, and a counter going backwards means the relay -//! restarted or the entry was garbage collected and re-created, so consumers -//! should treat a decrease as a fresh segment. +//! (default prefix `.stats`), or one per group of leading broadcast-path +//! segments at `//node/`; parse announce paths back with +//! [`parse_node_path`]. Each [`Tier`] carries `publisher.json`, +//! `subscriber.json`, and `sessions.json` tracks of cumulative [`Traffic`] and +//! [`Presence`] counters, plus `.json.z` siblings encoded with +//! [`moq_json::snapshot`]; compute names with [`traffic_track`] / +//! [`sessions_track`]. The full contract (paths, tracks, both encodings, and +//! counter semantics) is at . pub mod aggregate; pub mod consume; diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index 6f3171a37e..ed16002c1b 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.16](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.15...moq-tokio-v0.19.16) - 2026-09-25 + +### Fixed + +- *(net)* skip a stale warm cache on an IETF rejoin ([#4150](https://github.com/moq-dev/moq/pull/4150)) + +## [0.19.15](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.14...moq-tokio-v0.19.15) - 2026-09-25 + +### Fixed + +- *(moq-tokio)* release the QUIC socket before Listener::close returns ([#4087](https://github.com/moq-dev/moq/pull/4087)) + +### Other + +- *(moq-tokio)* dial the WebSocket fallback on its own ephemeral port ([#4084](https://github.com/moq-dev/moq/pull/4084)) + ## [0.19.14](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.13...moq-tokio-v0.19.14) - 2026-09-25 ### Added diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index f07b31921c..23f1008a84 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.14" +version = "0.19.16" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 7eb7b89c74..3dbbe6a889 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -205,7 +205,7 @@ pub(crate) struct Legacy { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", @@ -595,7 +595,7 @@ pub struct Config { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index d3e4d1422a..2d2cea2e92 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -129,7 +129,7 @@ pub struct Config { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", @@ -253,7 +253,7 @@ pub(crate) struct Legacy { "moq-lite-04", "moq-lite-05", "moq-lite-06", - "moq-lite-07", + "moq-lite-07-wip", "moq-transport-14", "moq-transport-15", "moq-transport-16", diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 3eff0a194f..d71a05f658 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -737,6 +737,12 @@ impl Listener { self.server.websocket_local_addr() } + /// The address the plain TCP (qmux) listener bound to, if one was configured. + #[cfg(feature = "tcp")] + pub fn tcp_local_addr(&self) -> Option { + self.server.streams.tcp_local_addr + } + /// A live handle to the certificates this server is serving. /// /// See [`Server::certificates`], which is also readable before listening. @@ -833,6 +839,9 @@ struct StreamListeners { versions: moq_net::Versions, #[cfg(all(feature = "uds", unix))] unix_allow: Option, + /// The address the TCP listener bound, once [`Self::start`] has run. + #[cfg(feature = "tcp")] + tcp_local_addr: Option, rx: Option>, tasks: Vec>, } @@ -854,6 +863,8 @@ impl StreamListeners { versions, #[cfg(all(feature = "uds", unix))] unix_allow, + #[cfg(feature = "tcp")] + tcp_local_addr: None, rx: None, tasks: Vec::new(), } @@ -886,7 +897,9 @@ impl StreamListeners { .await? .with_protocols(alpns) .with_accept_health(health); - tracing::info!(%addr, "listening (tcp)"); + let local = listener.local_addr()?; + tracing::info!(addr = %local, "listening (tcp)"); + self.tcp_local_addr = Some(local); bound.push(BoundListener::Tcp(listener)); } #[cfg(all(feature = "uds", unix))] diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 5b7f244665..3a0ddbacae 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -994,6 +994,267 @@ async fn broadcast_route_migration() { handle_b.await.expect("server b panicked").expect("server b failed"); } +/// A subscriber returning to a parked track is not handed the parked cache when the +/// upstream resolves its start past it: lite-06+ resolves the start from the budget, and +/// an IETF live join starts at the group SUBSCRIBE_OK names as Largest. +/// +/// The front keeps what an unread track delivered as a warm cache. While parked, the +/// publisher moved on, so the resumed upstream subscription starts well past that cache. +/// The cache was only fresh against its own frozen edge; serving it first put a +/// rejoining player seconds behind live. +/// +/// Pre-06 lite is skipped: nothing on those wires says where a subscription starts. +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_rejoin_skips_a_stale_warm_cache() { + let pre06 = [ + "moq-lite-01", + "moq-lite-02", + "moq-lite-03", + "moq-lite-04", + "moq-lite-05", + ]; + for version in moq_net::Version::names().filter(|version| !pre06.contains(version)) { + rejoin_skips_a_stale_warm_cache(version).await; + } +} + +async fn rejoin_skips_a_stale_warm_cache(version: &str) { + use moq_net::Timestamp; + + let ms = |ms: u64| Timestamp::from_millis(ms).unwrap(); + let write = |track: &moq_net::track::Producer, sequence: u64, at: u64| { + let mut group = track + .create_group(moq_net::group::Info { sequence }) + .expect("create group"); + group.write_frame(ms(at), b"frame".as_ref()).expect("write frame"); + group.finish().expect("finish group"); + }; + + let pub_origin = moq_tokio::origin::spawn(); + let broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("audio", None).expect("create track"); + let live = track.clone(); + for sequence in 0..4u64 { + write(&track, sequence, sequence * 20); + } + + let mut config = moq_tokio::listen::Config::default(); + config.bind = Some("[::]:0".parse().unwrap()); + config.tls.generate = vec!["localhost".into()]; + config.version = vec![version.parse().unwrap()]; + let mut server = config + .init(Default::default()) + .expect("init server") + .listen() + .await + .expect("listen"); + let addr = server.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let request = server.accept().await.expect("accept"); + let session = request.with_publisher(&pub_origin).ok().await?; + let _broadcast = broadcast; + let _track = track; + let _ = session.closed().await; + Ok::<_, anyhow::Error>(()) + }); + + let sub_origin = moq_tokio::origin::spawn(); + let sub_consumer = sub_origin.consume(); + let mut announcements = sub_consumer.announced(); + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.version = vec![version.parse().unwrap()]; + let client = config.init(Default::default()).expect("init client"); + let url: url::Url = format!("moqt://localhost:{}", addr.port()).parse().unwrap(); + let (_client, session) = tokio::time::timeout(TIMEOUT, connect_once(client.with_subscriber(sub_origin), url)) + .await + .expect("connect timeout") + .expect("connect failed"); + + assert!(next_announce(&mut announcements).await.kind.is_active()); + let remote = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) + .await + .expect("request timeout") + .expect("broadcast resolves"); + let budget = moq_net::track::Subscription::default().with_max_age(Duration::from_millis(100)); + async fn recv(sub: &mut moq_net::track::Subscriber) -> u64 { + tokio::time::timeout(TIMEOUT, sub.recv_group()) + .await + .expect("recv timeout") + .expect("recv failed") + .expect("track ended") + .sequence + } + + let mut sub = remote + .track("audio") + .unwrap() + .subscribe(budget.clone()) + .await + .expect("subscribe"); + recv(&mut sub).await; + drop(sub); + + // The front parks the track and cancels upstream, while the publisher moves on. + tokio::time::timeout(TIMEOUT, live.unused()) + .await + .expect("upstream never canceled") + .expect("track open"); + for sequence in 4..=20u64 { + write(&live, sequence, 10_000 + (sequence - 4) * 20); + } + + let mut sub = remote + .track("audio") + .unwrap() + .subscribe(budget) + .await + .expect("resubscribe"); + let first = recv(&mut sub).await; + assert!( + first > 4, + "{version}: a rejoining reader was served the stale cache first: group {first}" + ); + let mut sequence = first; + while sequence < 20 { + sequence = recv(&mut sub).await; + assert!( + sequence >= 4, + "{version}: a rejoining reader was served stale group {sequence}" + ); + } + + drop(sub); + drop(session); + server.await.expect("server panicked").expect("server failed"); +} + +/// A subscriber returning to a parked track whose newest group is still current gets it +/// back, then the live feed resumes: the re-splice asks for that group's tail, which the +/// publisher answers at once. Asking past it would wait for a group that a quiet track (a +/// catalog) may never send. Covers a finished newest group (a catalog) and one that stays +/// open (a JSON log appending frames to group 0), on every version. +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_rejoin_replays_a_current_warm_cache() { + for version in moq_net::Version::names() { + for open in [false, true] { + rejoin_replays_a_current_warm_cache(version, open).await; + } + } +} + +async fn rejoin_replays_a_current_warm_cache(version: &str, open: bool) { + let pub_origin = moq_tokio::origin::spawn(); + let broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("catalog.json", None).expect("create track"); + let live = track.clone(); + let mut group = live.append_group().expect("append group"); + group + .write_frame(moq_net::Timestamp::ZERO, b"v0".as_ref()) + .expect("write frame"); + if !open { + group.finish().expect("finish group"); + } + + let mut config = moq_tokio::listen::Config::default(); + config.bind = Some("[::]:0".parse().unwrap()); + config.tls.generate = vec!["localhost".into()]; + config.version = vec![version.parse().unwrap()]; + let mut server = config + .init(Default::default()) + .expect("init server") + .listen() + .await + .expect("listen"); + let addr = server.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + let request = server.accept().await.expect("accept"); + let session = request.with_publisher(&pub_origin).ok().await?; + let _broadcast = broadcast; + let _track = track; + let _ = session.closed().await; + Ok::<_, anyhow::Error>(()) + }); + + let sub_origin = moq_tokio::origin::spawn(); + let sub_consumer = sub_origin.consume(); + let mut announcements = sub_consumer.announced(); + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.version = vec![version.parse().unwrap()]; + let client = config.init(Default::default()).expect("init client"); + let url: url::Url = format!("moqt://localhost:{}", addr.port()).parse().unwrap(); + let (_client, session) = tokio::time::timeout(TIMEOUT, connect_once(client.with_subscriber(sub_origin), url)) + .await + .expect("connect timeout") + .expect("connect failed"); + + assert!(next_announce(&mut announcements).await.kind.is_active()); + let remote = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) + .await + .expect("request timeout") + .expect("broadcast resolves"); + + async fn recv(sub: &mut moq_net::track::Subscriber, ctx: &str) -> moq_net::group::Consumer { + tokio::time::timeout(TIMEOUT, sub.recv_group()) + .await + .unwrap_or_else(|_| panic!("{ctx}: recv_group timeout")) + .expect("recv_group failed") + .expect("track closed") + } + async fn read(group: &mut moq_net::group::Consumer, ctx: &str) -> String { + let frame = tokio::time::timeout(TIMEOUT, group.read_frame()) + .await + .unwrap_or_else(|_| panic!("{ctx}: read_frame timeout")) + .expect("read_frame failed") + .expect("group ended"); + String::from_utf8(frame.payload.to_vec()).unwrap() + } + + for round in 1..=3 { + let ctx = format!("{version} open={open} round {round}"); + let update = format!("v{round}"); + let mut sub = remote + .track("catalog.json") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"); + let mut reading = recv(&mut sub, &ctx).await; + if open { + for frame in 0..round { + assert_eq!(read(&mut reading, &ctx).await, format!("v{frame}"), "{ctx}"); + } + group + .write_frame(moq_net::Timestamp::ZERO, update.as_bytes()) + .expect("write frame"); + } else { + assert_eq!(read(&mut reading, &ctx).await, format!("v{}", round - 1), "{ctx}"); + let mut next = live.append_group().expect("append group"); + next.write_frame(moq_net::Timestamp::ZERO, update.as_bytes()) + .expect("write frame"); + next.finish().expect("finish group"); + reading = recv(&mut sub, &ctx).await; + } + // The live feed resumed behind the replayed cache. + assert_eq!(read(&mut reading, &ctx).await, update, "{ctx}"); + drop(reading); + drop(sub); + // The front parks the track and cancels upstream before the next round rejoins. + tokio::time::timeout(TIMEOUT, live.unused()) + .await + .unwrap_or_else(|_| panic!("{ctx}: upstream never canceled")) + .expect("track open"); + } + + drop(session); + server.await.expect("server panicked").expect("server failed"); +} + /// A publisher-side route update re-advertises downstream as a restart. /// /// The publisher re-prices its announced route with a longer chain; the @@ -1110,18 +1371,18 @@ async fn route_reannounce_test(version: Option<&str>) { handle.await.expect("server panicked").expect("server failed"); } -/// Route re-advertisement on the default version (lite-07: ANNOUNCE_RESTART by id). +/// Route re-advertisement on the default version (lite-06: ANNOUNCE_RESTART by id). #[tracing_test::traced_test] #[tokio::test] async fn broadcast_route_reannounce() { route_reannounce_test(None).await; } -/// Route re-advertisement on lite-06 (an explicit ANNOUNCE_RESTART by id). +/// Route re-advertisement on the opt-in lite-07-wip (an explicit ANNOUNCE_RESTART by id). #[tracing_test::traced_test] #[tokio::test] -async fn broadcast_route_reannounce_lite_06() { - route_reannounce_test(Some("moq-lite-06")).await; +async fn broadcast_route_reannounce_lite_07() { + route_reannounce_test(Some("moq-lite-07-wip")).await; } // ── Raw QUIC (moqt://) – same version on both sides ───────────────── @@ -1153,7 +1414,7 @@ async fn broadcast_moq_lite_06() { #[tracing_test::traced_test] #[tokio::test] async fn broadcast_moq_lite_07() { - broadcast_test("moqt", Some("moq-lite-07"), Some("moq-lite-07")).await; + broadcast_test("moqt", Some("moq-lite-07-wip"), Some("moq-lite-07-wip")).await; } #[tracing_test::traced_test] @@ -1877,7 +2138,9 @@ async fn broadcast_websocket_fallback() { /// /// Bump this whenever [`moq_net::Versions::all`] gains a newer Lite variant /// so the regression tests below keep tracking "the newest", not a frozen value. -const NEWEST_LITE: &str = "moq-lite-07"; +/// Work-in-progress versions (e.g. `moq-lite-07-wip`) are excluded from the default +/// set, so they don't count as "the newest" here until promoted. +const NEWEST_LITE: &str = "moq-lite-06"; /// Regression guard for the WebSocket ALPN path. Lite02 over WebSocket means /// the qmux subprotocol negotiation produced a bare `moql` (or no match) diff --git a/rs/moq-tokio/tests/reconnect.rs b/rs/moq-tokio/tests/reconnect.rs index 4eef51b3eb..a3532e354c 100644 --- a/rs/moq-tokio/tests/reconnect.rs +++ b/rs/moq-tokio/tests/reconnect.rs @@ -6,7 +6,6 @@ #![cfg(feature = "tcp")] -use std::net::TcpListener; use std::time::Duration; use moq_tokio::moq_net; @@ -103,45 +102,35 @@ async fn monitor_is_cloneable_without_keeping_the_connection_alive() { assert!(monitor.snapshot().is_none()); } -/// A stream-only moq server on a free loopback TCP port. +/// A stream-only moq server on an ephemeral loopback TCP port. /// /// Returns the port, a receiver yielding every accepted session (so a test can -/// drain one), and the listener task. The free-port probe can lose a race with -/// another test between the probe closing and the real bind, so retry rather -/// than panicking in `listen`. +/// drain one), and the listener task. async fn spawn_server() -> ( u16, tokio::sync::mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>, ) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - let server = config.init(Default::default()).expect("init server"); - let Ok(mut server) = server.listen().await else { - continue; - }; - - let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); - let handle = tokio::spawn(async move { - while let Some(request) = server.accept().await { - let origin = moq_tokio::origin::spawn(); - match request.with_publisher(&origin).ok().await { - Ok(session) => { - let _ = accepted.send(session); - } - Err(err) => tracing::warn!(%err, "accept failed"), + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + let server = config.init(Default::default()).expect("init server"); + let mut server = server.listen().await.expect("bind tcp listener"); + let port = server.tcp_local_addr().expect("tcp listener bound").port(); + + let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); + let handle = tokio::spawn(async move { + while let Some(request) = server.accept().await { + let origin = moq_tokio::origin::spawn(); + match request.with_publisher(&origin).ok().await { + Ok(session) => { + let _ = accepted.send(session); } + Err(err) => tracing::warn!(%err, "accept failed"), } - }); + } + }); - return (port, sessions, handle); - } - panic!("could not bind a free TCP port after 20 attempts"); + (port, sessions, handle) } /// A client that redials fast, so a refused redirect lands back on the original diff --git a/rs/moq-tokio/tests/worker.rs b/rs/moq-tokio/tests/worker.rs index f35ce6641d..70b727d005 100644 --- a/rs/moq-tokio/tests/worker.rs +++ b/rs/moq-tokio/tests/worker.rs @@ -5,7 +5,7 @@ //! the group. #![cfg(all(target_os = "linux", feature = "noq"))] -use std::net::{SocketAddr, UdpSocket}; +use std::net::UdpSocket; use moq_tokio::worker::{self, Workers}; @@ -13,8 +13,8 @@ const WORKERS: u16 = 4; /// A UDP port nothing is bound to. /// -/// Named rather than ephemeral because these tests rebind the port, or probe it -/// while the group holds it, which needs a port known before the group starts. +/// Only for the port-lock tests: an ephemeral group takes no lock, so the first +/// group has to name its port. Everything else binds `:0` and reads it back. fn free_udp_port() -> u16 { let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); let port = probe.local_addr().expect("local addr").port(); @@ -70,10 +70,9 @@ async fn dropping_the_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); assert_eq!(workers.len(), usize::from(WORKERS)); // Serving first is the case that used to strand the threads. The accept @@ -89,7 +88,6 @@ async fn dropping_the_workers_releases_the_port() { // A plain bind refuses a port any socket still holds, reuseport or not, so this // succeeds only if every worker's socket is really gone. - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -226,13 +224,11 @@ async fn dropping_unserved_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); drop(workers); - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -397,7 +393,7 @@ async fn generated_certificates_are_refused() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut listen = moq_tokio::listen::Config::default(); - listen.bind = Some(format!("127.0.0.1:{}", free_udp_port()).parse().unwrap()); + listen.bind = Some("127.0.0.1:0".parse().unwrap()); listen.tls.generate = vec!["localhost".to_string()]; let err = @@ -446,9 +442,8 @@ async fn dropping_a_server_keeps_its_socket() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let port = workers.local_addr().port(); let mut group = workers.split(); let sockets = udp_sockets_on(port); assert!(sockets >= 2, "every member holds at least one socket"); @@ -489,9 +484,8 @@ async fn completing_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); assert_eq!(members.len(), 2); @@ -529,7 +523,6 @@ async fn completing_a_member_stops_its_siblings() { .expect("a stopped sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -540,9 +533,8 @@ async fn cancelling_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -574,7 +566,6 @@ async fn cancelling_a_member_stops_its_siblings() { .expect("a cancelled sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -586,9 +577,8 @@ async fn a_panicking_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -620,7 +610,6 @@ async fn a_panicking_member_stops_its_siblings() { .expect("a panicking sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -631,9 +620,8 @@ async fn shutdown_with_work_in_flight_joins() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); for member in group.members() { @@ -650,7 +638,6 @@ async fn shutdown_with_work_in_flight_joins() { .expect("shutdown must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("shutdown must release the port"); } @@ -662,9 +649,8 @@ async fn dropping_the_group_with_work_in_flight_stops() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); { @@ -684,6 +670,5 @@ async fn dropping_the_group_with_work_in_flight_stops() { .expect("dropping the group must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("dropping the group must release the port"); } diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index 8dc782f3de..1e5792541a 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.4](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.3...moq-transcode-v0.1.4) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, moq-mux, moq-video, hang + +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.2...moq-transcode-v0.1.3) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux, moq-video + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.1...moq-transcode-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index c85edef892..0cde22ea92 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.2" +version = "0.1.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index 5275d2dfa5..beea4c6ba5 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.6](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.5...moq-uring-v0.0.6) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + +## [0.0.5](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.4...moq-uring-v0.0.5) - 2026-09-25 + +### Other + +- updated the following local packages: moq-net + ## [0.0.4](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.3...moq-uring-v0.0.4) - 2026-09-25 ### Added diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 5f74c6dfdd..35163a9583 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.4" +version = "0.0.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 8525c9ca02..137a3e22f5 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.4](https://github.com/moq-dev/moq/compare/moq-video-v0.1.3...moq-video-v0.1.4) - 2026-09-25 + +### Other + +- *(capture)* drive native capture through clock edge cases in CI ([#4125](https://github.com/moq-dev/moq/pull/4125)) + +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-video-v0.1.2...moq-video-v0.1.3) - 2026-09-25 + +### Added + +- *(mux)* measure encoder flush jitter per rendition ([#3940](https://github.com/moq-dev/moq/pull/3940)) + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-video-v0.1.1...moq-video-v0.1.2) - 2026-09-25 ### Other diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 9810c3c929..968a7c1fcb 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.2" +version = "0.1.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/src/capture/channel.rs b/rs/moq-video/src/capture/channel.rs index 1a56b944bb..9e32692bb4 100644 --- a/rs/moq-video/src/capture/channel.rs +++ b/rs/moq-video/src/capture/channel.rs @@ -27,18 +27,29 @@ pub(super) struct FrameChannel { struct State { frame: Option, #[cfg(any(target_os = "linux", target_os = "windows", test))] - native_anchor: Option<(Timestamp, Timestamp)>, + native: Option, closed: bool, error: Option, } +/// How a device's own timeline maps onto this stream's. +#[cfg(any(target_os = "linux", target_os = "windows", test))] +#[derive(Clone, Copy)] +struct Native { + /// A device timestamp and the local time it was anchored to. + source: Timestamp, + local: Timestamp, + /// The previous device timestamp, which the next must exceed to keep the anchor. + last: Timestamp, +} + impl FrameChannel { pub(super) fn new() -> Arc { Arc::new(Self { state: Mutex::new(State { frame: None, #[cfg(any(target_os = "linux", target_os = "windows", test))] - native_anchor: None, + native: None, closed: false, error: None, }), @@ -53,7 +64,7 @@ impl FrameChannel { self.push_at(frame, Instant::now()); } - fn push_at(&self, surface: Surface, captured: Instant) { + pub(super) fn push_at(&self, surface: Surface, captured: Instant) { let micros = captured.saturating_duration_since(self.epoch).as_micros(); let micros = u64::try_from(micros).unwrap_or(u64::MAX); let frame = Frame::new(surface, Timestamp::from_micros(micros).expect("capture timestamp fits")); @@ -61,9 +72,13 @@ impl FrameChannel { } /// Map a device-local timestamp into this stream's private timeline. The - /// source epoch never escapes: its first sample is anchored to acquisition. + /// source epoch never escapes: its first sample is anchored to arrival. /// Only the blocking-device pump feeds native timestamps, so it is gated like /// `pump` plus `cfg(test)` for the mapping test below. + /// + /// A device timeline that steps back or stalls (a driver restarting its clock + /// at zero, or one reporting a constant) re-anchors that sample to arrival, so + /// the stream never rewinds or repeats a timestamp it already delivered. #[cfg(any(target_os = "linux", target_os = "windows", test))] pub(super) fn push_native(&self, surface: Surface, source: Timestamp) { let local = self.now(); @@ -71,11 +86,19 @@ impl FrameChannel { if state.closed { return; } - let (source_anchor, local_anchor) = *state.native_anchor.get_or_insert((source, local)); + let anchor = match state.native { + Some(native) if source > native.last => native, + _ => Native { + source, + local, + last: source, + }, + }; let timestamp = source - .checked_sub(source_anchor) - .and_then(|elapsed| local_anchor.checked_add(elapsed)) + .checked_sub(anchor.source) + .and_then(|elapsed| anchor.local.checked_add(elapsed)) .unwrap_or(local); + state.native = Some(Native { last: source, ..anchor }); state.frame = Some(Frame::new(surface, timestamp)); drop(state); self.notify.notify_one(); @@ -256,4 +279,42 @@ mod tests { assert_eq!(second.as_micros() - first.as_micros(), 33_367); assert!(first.as_micros() < 9_000_000); } + + /// A device clock that restarts at zero mid-stream must not rewind the stream's + /// timeline: the sample re-anchors to arrival and the device's spacing resumes from there. + #[tokio::test] + async fn native_timestamps_reanchor_when_the_device_clock_restarts() { + let chan = FrameChannel::new(); + let us = |micros| Timestamp::from_micros(micros).unwrap(); + + chan.push_native(frame(1), us(0)); + chan.recv().await.unwrap().unwrap(); + // Real time passes with the device clock, so the mapping stays at or behind arrival. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + chan.push_native(frame(2), us(20_000)); + let before = chan.recv().await.unwrap().unwrap().timestamp; + + chan.push_native(frame(3), us(0)); + let restarted = chan.recv().await.unwrap().unwrap().timestamp; + assert!(restarted >= before, "{restarted:?} rewound behind {before:?}"); + + chan.push_native(frame(4), us(33_000)); + let next = chan.recv().await.unwrap().unwrap().timestamp; + assert_eq!(next.as_micros() - restarted.as_micros(), 33_000); + } + + /// A driver that reports one constant timestamp must not stamp every frame + /// identically: each sample falls back to its arrival. + #[tokio::test] + async fn a_stalled_device_clock_falls_back_to_arrival() { + let chan = FrameChannel::new(); + let constant = Timestamp::from_micros(0).unwrap(); + + chan.push_native(frame(1), constant); + let first = chan.recv().await.unwrap().unwrap().timestamp; + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + chan.push_native(frame(2), constant); + let second = chan.recv().await.unwrap().unwrap().timestamp; + assert!(second > first, "a stalled device clock repeated {first:?}"); + } } diff --git a/rs/moq-video/src/capture/mod.rs b/rs/moq-video/src/capture/mod.rs index 25fe9784c5..f39ff45389 100644 --- a/rs/moq-video/src/capture/mod.rs +++ b/rs/moq-video/src/capture/mod.rs @@ -364,6 +364,46 @@ impl Stream { } } +/// A test-driven capture device: fixtures push frames at explicit acquisition +/// instants or device timestamps, standing in for a camera behind a [`Stream`]. +#[cfg(test)] +pub(crate) struct Synthetic { + chan: Arc, +} + +#[cfg(test)] +impl Synthetic { + /// Open a synthetic device with the given geometry, returning it and the stream reading it. + pub(crate) fn open(size: crate::Size, framerate: Rate) -> (Self, Stream) { + let chan = FrameChannel::new(); + let stream = Stream::new( + chan.clone(), + size.width, + size.height, + Some(framerate), + "synthetic".to_string(), + None, + Box::new(()), + ); + (Self { chan }, stream) + } + + /// Deliver a frame acquired at `captured`. + pub(crate) fn push_at(&self, surface: crate::frame::Surface, captured: std::time::Instant) { + self.chan.push_at(surface, captured); + } + + /// Deliver a frame stamped on the device's own timeline, like V4L2 and Media Foundation. + pub(crate) fn push_native(&self, surface: crate::frame::Surface, source: moq_net::Timestamp) { + self.chan.push_native(surface, source); + } + + /// End the stream, as a device that stops delivering does. + pub(crate) fn close(&self) { + self.chan.close(); + } +} + /// Open the capture source described by `config`. pub async fn open(config: &Config) -> Result { match &config.source { diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index c6daa5c500..86353d907b 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -322,7 +322,7 @@ pub async fn publish_capture( let mut producer = Producer::new(broadcast, catalog, rendition)?; let demand = producer.demand(); - let result = capture_loop(&mut producer, &demand, &capture, &encode, &clock).await; + let result = capture_loop(&mut producer, &demand, &mut DeviceSource, &capture, &encode, &clock).await; // This runs only when the loop ends on its own (the track is usually already // going away by then); a Ctrl+C cancels the future before this point, since @@ -358,6 +358,23 @@ fn assert_publish_capture_send( is_send(&publish_capture(broadcast, catalog, capture, encode, clock)); } +/// Where the capture loop opens its camera. Kept apart from the device backends so +/// the clock fixtures can drive the real loop from a synthetic source. +#[cfg(feature = "capture")] +trait CaptureSource { + async fn open(&mut self, config: &capture::Config) -> Result; +} + +#[cfg(feature = "capture")] +struct DeviceSource; + +#[cfg(feature = "capture")] +impl CaptureSource for DeviceSource { + async fn open(&mut self, config: &capture::Config) -> Result { + capture::open(config).await + } +} + /// The live rate control state: the estimate source paired with the policy tracking /// it. `None` once it has *retired*, which is the only thing absence means now that /// every encoder has a share to read: an allocator with nothing to divide grants @@ -473,9 +490,10 @@ async fn wait_capture( /// so their joins return promptly unless the underlying device or encoder is /// itself wedged. #[cfg(feature = "capture")] -async fn capture_loop( +async fn capture_loop( producer: &mut Producer, demand: &moq_net::track::Demand, + source: &mut S, capture: &capture::Config, encode: &Options, clock: &moq_mux::Clock, @@ -495,7 +513,7 @@ async fn capture_loop( } // Open the camera and an encoder sized to its negotiated mode. - let Some(mut camera) = wait_capture(producer, demand, capture::open(capture)).await? else { + let Some(mut camera) = wait_capture(producer, demand, source.open(capture)).await? else { continue; }; // Capture timestamps use a private monotonic timeline. Sample both clocks @@ -858,4 +876,343 @@ mod tests { assert_eq!(config.coded_width, Some(320)); assert_eq!(config.coded_height, Some(240)); } + + /// Clock fixtures: the real capture loop, fed by a synthetic camera against a pinned + /// broadcast clock, graded on the timestamps a subscriber reads back. + /// + /// Each expectation is the acquisition instant measured on the broadcast clock. The loop + /// samples the broadcast clock and then the camera's timeline when it opens a camera, so a + /// published timestamp may land up to `SAMPLING` early, never late. + #[cfg(all(feature = "capture", feature = "openh264"))] + mod clock { + use std::time::{Duration, Instant, SystemTime}; + + use super::*; + use crate::capture::Synthetic; + + /// How early a mapped timestamp may land: the gap between the loop's two clock samples. + const SAMPLING: Duration = Duration::from_millis(250); + /// Rounding slack on the late side: each clock reading truncates to a microsecond. + const ROUNDING: u64 = 2; + /// Retain every fixture group, so a slow runner never evicts one before it is read. + const RETAIN: Duration = Duration::from_secs(600); + + /// Hands the loop one fixture-supplied stream per camera open. + struct Opens(tokio::sync::mpsc::UnboundedReceiver); + + impl CaptureSource for Opens { + async fn open(&mut self, _config: &capture::Config) -> Result { + self.0 + .recv() + .await + .ok_or_else(|| Error::SourceUnavailable("the fixture stopped opening cameras".to_string())) + } + } + + struct Fixture { + epoch: Instant, + clock: moq_mux::Clock, + catalog: moq_mux::catalog::Producer, + consumer: moq_net::broadcast::Consumer, + _broadcast: moq_net::broadcast::Producer, + opens: tokio::sync::mpsc::UnboundedSender, + stop: Option>, + task: tokio::task::JoinHandle>, + } + + impl Fixture { + /// Start the capture loop on a broadcast whose clock began `behind` ago, at `wall`. + async fn start(behind: Duration, wall: SystemTime) -> Self { + let epoch = Instant::now() + .checked_sub(behind) + .expect("a monotonic clock that far back"); + let clock = moq_mux::Clock::at(epoch, wall).unwrap(); + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let config = moq_mux::catalog::Config::default() + .with_clock(clock) + .with_max_age(RETAIN); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap(); + let track = broadcast + .create_track( + "video", + catalog.track_info(hang::catalog::PRIORITY.video).with_max_age(RETAIN), + ) + .unwrap(); + + let mut probe = Config::new(320, 240, crate::Rate::new(30, 1).unwrap()); + probe.kind = encoder::Kind::Software; + let mut producer = Producer::with_track(track, catalog.clone(), probe.probe().await.unwrap()).unwrap(); + let demand = producer.demand(); + + let (opens, rx) = tokio::sync::mpsc::unbounded_channel(); + let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(async move { + let mut source = Opens(rx); + let options = Options { + kind: encoder::Kind::Software, + ..Options::default() + }; + let config = capture::Config::default(); + tokio::select! { + res = capture_loop(&mut producer, &demand, &mut source, &config, &options, &clock) => res?, + _ = stopped => {} + } + producer.finish() + }); + + Self { + epoch, + clock, + catalog, + consumer, + _broadcast: broadcast, + opens, + stop: Some(stop), + task, + } + } + + /// Subscribe to the video track, which is what opens the camera. + async fn subscribe(&self) -> moq_mux::container::Consumer { + let snapshot = self.catalog.snapshot(); + let (name, rendition) = snapshot.video.renditions.iter().next().expect("the probed rendition"); + let container = moq_mux::catalog::hang::Container::try_from(rendition).unwrap(); + let track = self + .consumer + .track(name) + .unwrap() + .subscribe(moq_net::track::Subscription::default().with_max_age(RETAIN)) + .await + .unwrap(); + moq_mux::container::Consumer::new(track, container) + } + + /// Plug in the camera the loop opens next. + fn camera(&self) -> Synthetic { + let (camera, stream) = Synthetic::open(crate::Size::new(320, 240), crate::Rate::new(30, 1).unwrap()); + self.opens.send(stream).unwrap(); + camera + } + + /// `instant` on the broadcast clock, in microseconds. + fn at(&self, instant: Instant) -> u64 { + u64::try_from(instant.duration_since(self.epoch).as_micros()).unwrap() + } + + /// Stop the loop and finalize the track, as a clean end of capture does. + async fn finish(mut self) -> (moq_mux::catalog::Producer, moq_net::broadcast::Consumer) { + let _ = self.stop.take().expect("finished once").send(()); + self.task.await.unwrap().unwrap(); + (self.catalog, self.consumer) + } + + /// A frame acquired at `captured` publishes at that instant on the broadcast clock. + fn assert_acquired(&self, published: u64, captured: Instant) { + let exact = self.at(captured); + let early = u64::try_from(SAMPLING.as_micros()).unwrap(); + assert!( + published + early >= exact && published <= exact + ROUNDING, + "published {published}us, acquired at {exact}us on the broadcast clock" + ); + } + } + + fn surface() -> crate::frame::Surface { + crate::frame::Surface::I420(crate::frame::I420 { + width: 320, + height: 240, + data: vec![0x80; 320 * 240 * 3 / 2], + color: None, + }) + } + + fn us(micros: u64) -> Timestamp { + Timestamp::from_micros(micros).unwrap() + } + + async fn read(track: &mut moq_mux::container::Consumer) -> u64 { + let frame = track.read().await.unwrap().expect("a published frame"); + u64::try_from(frame.timestamp.as_micros()).unwrap() + } + + /// Read the next frame not already in `seen`: a resubscription replays retained groups first. + async fn read_new( + track: &mut moq_mux::container::Consumer, + seen: &[u64], + ) -> u64 { + loop { + let timestamp = read(track).await; + if !seen.contains(×tamp) { + return timestamp; + } + } + } + + /// A camera whose first frame arrives long after the broadcast began stamps it at its + /// acquisition: not zero, and not the later instant the loop dequeued it. + #[tokio::test] + async fn a_late_first_frame_publishes_its_acquisition() { + let fixture = Fixture::start(Duration::from_secs(5), SystemTime::now()).await; + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + + let captured = Instant::now(); + // Delivered well after acquisition: dequeue time must not leak into the timestamp. + tokio::time::sleep(Duration::from_millis(50)).await; + camera.push_at(surface(), captured); + let published = read(&mut track).await; + + assert!(published >= 4_000_000, "{published}us restarted the broadcast at zero"); + fixture.assert_acquired(published, captured); + fixture.finish().await; + } + + /// A device clock that restarts at zero, mid-stream or across a reopen, continues the + /// broadcast forward with the device's spacing instead of rewinding it. + #[tokio::test] + async fn a_device_clock_restart_continues_forward() { + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + + // The device numbers from zero, and real time keeps pace with it. + camera.push_native(surface(), us(0)); + let first = read(&mut track).await; + tokio::time::sleep(Duration::from_millis(40)).await; + camera.push_native(surface(), us(40_000)); + let second = read(&mut track).await; + assert_eq!(second - first, 40_000, "the device's spacing survives"); + + // The device restarts its clock without the stream ending. + camera.push_native(surface(), us(0)); + let restarted = read(&mut track).await; + assert!(restarted >= second, "{restarted}us rewound behind {second}us"); + tokio::time::sleep(Duration::from_millis(40)).await; + camera.push_native(surface(), us(40_000)); + let resumed = read(&mut track).await; + assert_eq!(resumed - restarted, 40_000, "the device's spacing resumes"); + + // The device goes away and comes back numbering from zero again. + camera.close(); + let camera = fixture.camera(); + let pushed = Instant::now(); + camera.push_native(surface(), us(0)); + let reopened = read(&mut track).await; + let arrived = fixture.at(Instant::now()); + assert!(reopened >= resumed, "{reopened}us rewound across the reopen"); + let early = u64::try_from(SAMPLING.as_micros()).unwrap(); + assert!(reopened + early >= fixture.at(pushed) && reopened <= arrived + ROUNDING); + fixture.finish().await; + } + + /// Releasing the camera while nobody watches keeps the broadcast clock running: the + /// frame after a resume lands after the real idle gap, at its own acquisition. + #[tokio::test] + async fn a_restart_after_idle_keeps_the_gap() { + let idle = Duration::from_millis(300); + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let before = read(&mut track).await; + fixture.assert_acquired(before, captured); + drop(track); + drop(camera); + + tokio::time::sleep(idle).await; + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let after = read_new(&mut track, &[before]).await; + fixture.assert_acquired(after, captured); + assert!( + after - before >= u64::try_from(idle.as_micros()).unwrap(), + "the {idle:?} idle gap collapsed to {}us", + after - before + ); + fixture.finish().await; + } + + /// The wall mapping is pinned when the broadcast clock is built. A system clock stepped + /// an hour since then retimes neither the published timestamps nor the advertised mapping. + #[tokio::test] + async fn a_system_wall_adjustment_retimes_nothing() { + // Whole seconds, so the advertised mapping holds it exactly. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() - 3600); + let fixture = Fixture::start(Duration::from_secs(1), wall).await; + let advertised = fixture.catalog.snapshot().clock; + assert_eq!(advertised, Some(fixture.clock.wall())); + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let published = read(&mut track).await; + + // Timestamps follow the monotonic epoch and map to walls under the pinned mapping. + fixture.assert_acquired(published, captured); + let mapped = advertised.unwrap().wall_clock(us(published)).unwrap(); + // The catalog maps to walls at millisecond precision. + assert_eq!(mapped, wall + Duration::from_millis(published / 1000)); + assert_eq!(fixture.catalog.snapshot().clock, advertised); + fixture.finish().await; + } + + /// A recording replays what the live edge published: the archive's segment records + /// carry the live timestamps across an idle restart, with the idle gap left in. + #[tokio::test] + async fn retained_archive_playback_keeps_the_live_timestamps() { + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + let section = fixture + .catalog + .snapshot() + .archive + .expect("the video track enrolls an archive"); + let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion) + .await + .unwrap(); + + let mut live = Vec::new(); + for _ in 0..2 { + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let published = read_new(&mut track, &live).await; + fixture.assert_acquired(published, captured); + live.push(published); + drop(track); + // Idle past the minimum segment, so each run is archived as its own segment. + tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + } + + let (catalog, _consumer) = fixture.finish().await; + catalog.timeline().finish().unwrap(); + let mut archived = Vec::new(); + while let Some(event) = timeline.next().await.unwrap() { + match event { + moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), + other => panic!("unexpected timeline event {other:?}"), + } + } + + assert_eq!(archived.len(), live.len(), "one segment per capture run: {archived:?}"); + for (entry, live) in archived.iter().zip(&live) { + // The archive keeps millisecond precision. + assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); + assert!(entry.tracks.contains_key("video"), "{archived:?}"); + } + let first = &archived[0]; + assert!( + archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), + "the resumed segment overlaps the one before it: {archived:?}" + ); + } + } } diff --git a/test/wasm/README.md b/test/wasm/README.md index 9ee699c24d..a1eb599b49 100644 --- a/test/wasm/README.md +++ b/test/wasm/README.md @@ -56,7 +56,7 @@ One per protocol flavour, since negotiation is the part that broke: | name | relay | negotiates | | ------- | -------------------------------- | -------------------------------------- | -| `lite` | defaults | `moq-lite-07`, over its own ALPN | +| `lite` | defaults | `moq-lite-06`, over its own ALPN | | `ietf` | `--listen-version moq-transport-19` | `moq-transport-19`, over its own ALPN | | `setup` | `--listen-version moq-lite-02` | the `moql` ALPN, version chosen by SETUP | @@ -99,7 +99,7 @@ here becomes a second wasm session and the interop runs both ways. Firefox. Playwright can launch it and it opens WebTransport sessions here, but it ships no `WebTransport.prototype.protocol`, so it cannot request or read a subprotocol. Run against Firefox 153, the `lite` relay negotiates `moq-lite-02` -over SETUP instead of `moq-lite-07` and the `ietf` relay rejects the connection +over SETUP instead of `moq-lite-06` and the `ietf` relay rejects the connection outright: four of the nine cases fail, including the version-negotiation case this harness exists for. There is nothing left to assert about negotiation, so Firefox joins the matrix when Gecko implements the subprotocol, not before. diff --git a/test/wasm/run.sh b/test/wasm/run.sh index 4b33db7af9..e2e649743e 100755 --- a/test/wasm/run.sh +++ b/test/wasm/run.sh @@ -9,7 +9,7 @@ # generated `js/wasm/dist` loaded by a real browser. # # One relay per protocol flavour, because negotiation is the part that broke: -# lite default versions -> moq-lite-07 over its own ALPN +# lite default versions -> moq-lite-06 over its own ALPN # ietf --listen-version 19 -> moq-transport-19 over its own ALPN # setup --listen-version lite-02 -> the "moql" ALPN, version chosen by SETUP # @@ -64,7 +64,7 @@ fi # name:version-flag:expected-version. An empty flag leaves the relay at its # defaults, which is the ALPN both sides prefer. FLAVOURS=( - "lite::moq-lite-07" + "lite::moq-lite-06" "ietf:moq-transport-19:moq-transport-19" "setup:moq-lite-02:moq-lite-02" )